From dd552adf19d716e3d1ec145420622c0327624e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 5 Aug 2026 23:09:40 +0200 Subject: [PATCH 01/56] feat(analytics): add shared event contract --- apps/mobile/src/lib/analytics/events.ts | 19 ++ apps/mobile/src/lib/analytics/posthog.test.ts | 62 ++++ apps/mobile/src/lib/analytics/posthog.ts | 75 ++++- apps/mobile/src/lib/appsflyer.test.ts | 2 +- apps/mobile/src/lib/appsflyer.ts | 9 +- .../src/lib/analytics-outbox/capture.test.ts | 167 ++++++++++ apps/web/src/lib/analytics-outbox/capture.ts | 126 +++++++ docs/analytics-event-catalog.md | 175 ++++++++++ packages/app-shared/package.json | 3 +- .../src/analytics/event-map.test.ts | 312 +++++++++++++++++ .../app-shared/src/analytics/event-map.ts | 315 ++++++++++++++++++ packages/app-shared/src/analytics/index.ts | 2 + packages/app-shared/src/analytics/privacy.ts | 48 +++ 13 files changed, 1293 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/lib/analytics/events.ts create mode 100644 apps/web/src/lib/analytics-outbox/capture.test.ts create mode 100644 apps/web/src/lib/analytics-outbox/capture.ts create mode 100644 docs/analytics-event-catalog.md create mode 100644 packages/app-shared/src/analytics/event-map.test.ts create mode 100644 packages/app-shared/src/analytics/event-map.ts create mode 100644 packages/app-shared/src/analytics/index.ts create mode 100644 packages/app-shared/src/analytics/privacy.ts diff --git a/apps/mobile/src/lib/analytics/events.ts b/apps/mobile/src/lib/analytics/events.ts new file mode 100644 index 0000000000..689d918061 --- /dev/null +++ b/apps/mobile/src/lib/analytics/events.ts @@ -0,0 +1,19 @@ +/** + * Mobile-side bindings for the shared analytics event contract + * (`@kilocode/app-shared/analytics`, P1-A-07a / DEC-05). + * + * Event names, payload schemas, and the phase classification come from the + * shared map; this file is the stable mobile import surface and adds the + * mobile type aliases (`AnalyticsSurface`, `SessionOpenedVia`) that legacy + * call sites use. `posthog.ts` re-exports these names so existing + * `@/lib/analytics/posthog` imports keep working unchanged. + */ +import { type ANALYTICS_SURFACES, type SESSION_OPENED_VIA } from '@kilocode/app-shared/analytics'; + +export * from '@kilocode/app-shared/analytics'; + +/** Legacy mobile surface values (existing payloads, unchanged). */ +export type AnalyticsSurface = (typeof ANALYTICS_SURFACES)[number]; + +/** How a session screen was opened: push notification or in-app navigation. */ +export type SessionOpenedVia = (typeof SESSION_OPENED_VIA)[number]; diff --git a/apps/mobile/src/lib/analytics/posthog.test.ts b/apps/mobile/src/lib/analytics/posthog.test.ts index 98d0ac8726..124d8a27a6 100644 --- a/apps/mobile/src/lib/analytics/posthog.test.ts +++ b/apps/mobile/src/lib/analytics/posthog.test.ts @@ -247,6 +247,68 @@ describe('capture gate and generation scoping', () => { }); }); +describe('captureUncataloged privacy and gates', () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.holder.options = undefined; + hoisted.controller.allowsOptional.mockReturnValue(true); + hoisted.controller.currentGeneration.mockReturnValue(0); + }); + + it('drops payload keys that name a prohibited data class before capture', async () => { + const { initPostHog, captureUncataloged } = await loadModule(); + initPostHog(); + captureUncataloged('onboarding-entered', { + surface: 'claw', + email: 'a@b.co', + session_id: 'x', + ok_count: 1, + }); + + expect(hoisted.client.capture).toHaveBeenCalledWith('onboarding-entered', { + surface: 'claw', + ok_count: 1, + }); + }); + + it('keeps every allowed key on an uncataloged payload', async () => { + const { initPostHog, captureUncataloged } = await loadModule(); + initPostHog(); + captureUncataloged('provision-failed', { category: 'lock' }); + + expect(hoisted.client.capture).toHaveBeenCalledWith('provision-failed', { category: 'lock' }); + }); + + it('passes no properties through unchanged when none are given', async () => { + const { initPostHog, captureUncataloged } = await loadModule(); + initPostHog(); + captureUncataloged('completion-reached'); + + expect(hoisted.client.capture).toHaveBeenCalledWith('completion-reached', undefined); + }); + + it('returns early when optional consent is not given', async () => { + hoisted.controller.allowsOptional.mockReturnValue(false); + const { initPostHog, captureUncataloged } = await loadModule(); + initPostHog(); + captureUncataloged('login', { surface: 'claw' }); + + expect(hoisted.client.capture).not.toHaveBeenCalled(); + }); + + it('drops a stale-generation capture', async () => { + hoisted.controller.currentGeneration.mockReturnValue(0); + const { initPostHog, captureUncataloged } = await loadModule(); + initPostHog(); + + // Bump the generation after init. + hoisted.controller.currentGeneration.mockReturnValue(1); + captureUncataloged('login', { surface: 'claw' }); + + expect(hoisted.client.capture).not.toHaveBeenCalled(); + }); +}); + describe('discardPostHog', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/mobile/src/lib/analytics/posthog.ts b/apps/mobile/src/lib/analytics/posthog.ts index 3a6ef5959e..fc2c6a2552 100644 --- a/apps/mobile/src/lib/analytics/posthog.ts +++ b/apps/mobile/src/lib/analytics/posthog.ts @@ -1,3 +1,4 @@ +import { type AnalyticsEventMap, redactProhibitedProperties } from '@kilocode/app-shared/analytics'; import * as Application from 'expo-application'; import * as Device from 'expo-device'; import PostHog, { PostHogPersistedProperty } from 'posthog-react-native'; @@ -37,22 +38,25 @@ import { * `register()`). We do not override the reserved `$device_type`. */ -export const SESSION_VIEWED_EVENT = 'session_viewed'; -export const MESSAGE_SENT_EVENT = 'message_sent'; -export const SESSION_CREATED_EVENT = 'session_created'; -export const PERMISSION_RESPONDED_EVENT = 'permission_responded'; -export const QUESTION_ANSWERED_EVENT = 'question_answered'; -export const CONVERSATION_CREATED_EVENT = 'conversation_created'; -export const INSTANCE_ACTION_EVENT = 'instance_action'; -export const FEEDBACK_SUBMITTED_EVENT = 'feedback_submitted'; -// Matches the event name web already captures — keep in sync for shared funnels. -export const ORGANIZATION_MEMBER_INVITED_EVENT = 'organization_member_invited'; -export const KILO_PASS_PURCHASE_STARTED_EVENT = 'kilo_pass_purchase_started'; -export const KILO_PASS_PURCHASE_COMPLETED_EVENT = 'kilo_pass_purchase_completed'; -export const KILO_PASS_PURCHASE_FAILED_EVENT = 'kilo_pass_purchase_failed'; -export const APP_STARTUP_EVENT = 'app_startup'; - -export type AnalyticsSurface = 'claw' | 'cloud-agent' | 'remote-session'; +// Event names and the mobile surface type come from the shared analytics +// contract (`./events`). Re-exported here so existing `@/lib/analytics/posthog` +// imports keep working unchanged. +export { + APP_STARTUP_EVENT, + CONVERSATION_CREATED_EVENT, + FEEDBACK_SUBMITTED_EVENT, + INSTANCE_ACTION_EVENT, + KILO_PASS_PURCHASE_COMPLETED_EVENT, + KILO_PASS_PURCHASE_FAILED_EVENT, + KILO_PASS_PURCHASE_STARTED_EVENT, + MESSAGE_SENT_EVENT, + ORGANIZATION_MEMBER_INVITED_EVENT, + PERMISSION_RESPONDED_EVENT, + QUESTION_ANSWERED_EVENT, + SESSION_CREATED_EVENT, + SESSION_VIEWED_EVENT, +} from './events'; +export type { AnalyticsSurface } from './events'; // PostHog feature flags. The project is shared with web, so mobile-only flags // are prefixed to avoid colliding with web flag keys. @@ -190,14 +194,51 @@ export function initPostHog(): void { }); } +export function captureEvent( + name: K, + properties?: AnalyticsEventMap[K] +): void; +// Fallback for legacy and test-only callers that pass a literal name outside +// the catalog. The `Exclude` keeps this overload from masking a mistyped +// payload on a cataloged event: when `name` is a map key, `name` is `never` +// here, so only the typed overload above can match. +export function captureEvent( + name: Exclude, + properties?: Record +): void; export function captureEvent( name: string, properties?: Record +): void { + captureWithPrivacy(name, properties); +} + +/** + * Captures an event whose name is not in the catalog (dynamic or + * AppsFlyer-mirrored names). Applies the same consent and generation gates as + * `captureEvent`, plus the runtime privacy deny-list: property keys naming a + * prohibited data class are dropped before capture. The AppsFlyer mirror path + * (`appsflyer.ts` `trackEvent`) is the only sanctioned caller. + */ +export function captureUncataloged( + name: string, + properties?: Record +): void { + captureWithPrivacy(name, properties); +} + +function captureWithPrivacy( + name: string, + properties?: Record ): void { if (!allowsOptional() || currentGeneration() !== clientGeneration) { return; } - client?.capture(name, properties); + // Redaction is a no-op for the cataloged strict-object payloads (their keys + // are deny-list-safe by schema); it guards the record-shaped `app_startup` + // payload and every uncataloged dynamic payload at runtime. + const safe = properties === undefined ? undefined : redactProhibitedProperties(properties); + client?.capture(name, safe); } export function captureScreen(name: string): void { diff --git a/apps/mobile/src/lib/appsflyer.test.ts b/apps/mobile/src/lib/appsflyer.test.ts index 4bfca283ff..d8811702c5 100644 --- a/apps/mobile/src/lib/appsflyer.test.ts +++ b/apps/mobile/src/lib/appsflyer.test.ts @@ -50,7 +50,7 @@ vi.mock('react-native-appsflyer', () => ({ })); vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); -vi.mock('@/lib/analytics/posthog', () => ({ captureEvent: vi.fn() })); +vi.mock('@/lib/analytics/posthog', () => ({ captureUncataloged: vi.fn() })); vi.mock('@/lib/config', () => ({ APPSFLYER_DEV_KEY: 'dev-key', APPSFLYER_APP_ID: 'app-id', diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index 96af146a3f..52c0a0a3ab 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -6,7 +6,7 @@ import appsFlyer, { StoreKitVersion, } from 'react-native-appsflyer'; -import { captureEvent } from '@/lib/analytics/posthog'; +import { captureUncataloged } from '@/lib/analytics/posthog'; import { APPSFLYER_APP_ID, APPSFLYER_DEV_KEY } from '@/lib/config'; import { allowsOptional, currentGeneration } from '@/lib/telemetry/controller'; @@ -205,8 +205,11 @@ export function trackEvent(name: string, values?: Record): void // Mirror attribution events into PostHog so the onboarding funnel is // visible in product analytics too. Both SDKs sit behind the same consent - // gate; captureEvent no-ops until PostHog is initialized. - captureEvent(name, eventValues); + // gate; `captureUncataloged` no-ops until PostHog is initialized and drops + // any payload key that names a prohibited data class. These are dynamic + // funnel names, so they use the uncataloged path rather than the typed + // `captureEvent`. + captureUncataloged(name, eventValues); if (!initialized) { pendingEvents.push({ name, values: eventValues, generation: currentGeneration() }); diff --git a/apps/web/src/lib/analytics-outbox/capture.test.ts b/apps/web/src/lib/analytics-outbox/capture.test.ts new file mode 100644 index 0000000000..921eef10db --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/capture.test.ts @@ -0,0 +1,167 @@ +import { captureException } from '@sentry/nextjs'; +import { after } from 'next/server'; + +import { captureCatalogEvent, runAfterResponse } from '@/lib/analytics-outbox/capture'; + +const mockCapture = jest.fn(); + +jest.mock('@sentry/nextjs', () => ({ + captureException: jest.fn(), +})); + +jest.mock('@/lib/posthog', () => ({ + __esModule: true, + default: jest.fn(() => ({ capture: mockCapture })), +})); + +// IS_IN_AUTOMATED_TEST makes runAfterResponse run its work inline so the +// tests can assert on the capture without waiting on `after()`. The getter +// lets the scheduling-failure tests below exercise the real post-response +// path with `after()` mocked from next/server. +const automatedTestState = { enabled: true }; +jest.mock('@/lib/config.server', () => ({ + get IS_IN_AUTOMATED_TEST() { + return automatedTestState.enabled; + }, +})); + +jest.mock('next/server', () => ({ + after: jest.fn(), +})); + +describe('captureCatalogEvent', () => { + beforeEach(() => { + automatedTestState.enabled = true; + mockCapture.mockReset(); + jest.mocked(captureException).mockReset(); + jest.mocked(after).mockReset(); + }); + + it('captures an accepted-phase event with distinctId, event, and properties', async () => { + captureCatalogEvent({ + distinctId: 'user@example.com', + event: 'session_created', + properties: { surface: 'cloud-agent' }, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'user@example.com', + event: 'session_created', + properties: { surface: 'cloud-agent' }, + }); + }); + + it('passes the record-shaped app_startup payload through unchanged', async () => { + captureCatalogEvent({ + distinctId: 'user@example.com', + event: 'app_startup', + properties: { outcome: 'app', auth_ready: 0, consent_ready: 60 }, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'user@example.com', + event: 'app_startup', + properties: { outcome: 'app', auth_ready: 0, consent_ready: 60 }, + }); + }); + + it('never throws to the caller when PostHog capture fails', async () => { + mockCapture.mockImplementation(() => { + throw new Error('posthog unavailable'); + }); + + expect(() => { + captureCatalogEvent({ + distinctId: 'user@example.com', + event: 'session_created', + properties: { surface: 'cloud-agent' }, + }); + }).not.toThrow(); + + // A macrotask flush drains the full microtask chain: capture call → bounded + // rejection → best-effort catch in captureAcceptedEvent. + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(jest.mocked(captureException)).toHaveBeenCalledTimes(1); + const [error, tags] = jest.mocked(captureException).mock.calls[0] ?? []; + expect((error as Error).message).toBe('posthog unavailable'); + expect(tags).toMatchObject({ tags: { source: 'analytics_capture_catalog_event' } }); + }); + + it('reports a rejected capture attempt as best-effort too', async () => { + mockCapture.mockImplementation(() => { + throw new Error('capture rejected'); + }); + + captureCatalogEvent({ + distinctId: 'user@example.com', + event: 'session_created', + properties: { surface: 'cloud-agent' }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(jest.mocked(captureException)).toHaveBeenCalledTimes(1); + }); +}); + +describe('runAfterResponse', () => { + it('runs the work inline when IS_IN_AUTOMATED_TEST is set', async () => { + let ran = false; + await runAfterResponse(async () => { + ran = true; + }); + expect(ran).toBe(true); + }); +}); + +describe('runAfterResponse outside automated tests', () => { + beforeEach(() => { + automatedTestState.enabled = false; + jest.mocked(captureException).mockReset(); + jest.mocked(after).mockReset(); + }); + + afterEach(() => { + automatedTestState.enabled = true; + }); + + it('reports a synchronous after() scheduling failure without an unhandled rejection', async () => { + jest.mocked(after).mockImplementation(() => { + throw new Error('after unavailable outside request scope'); + }); + + captureCatalogEvent({ + distinctId: 'user@example.com', + event: 'session_created', + properties: { surface: 'cloud-agent' }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(jest.mocked(captureException)).toHaveBeenCalledTimes(1); + const [error] = jest.mocked(captureException).mock.calls[0] ?? []; + expect((error as Error).message).toBe('after unavailable outside request scope'); + }); + + it('reports a rejected scheduled work promise without propagating or leaking it', async () => { + jest.mocked(after).mockImplementation(task => { + // next/server owns the callback promise; invoke it like the runtime. + if (typeof task === 'function') { + void Promise.resolve(task()).catch(() => undefined); + } + }); + + await expect( + runAfterResponse(() => Promise.reject(new Error('scheduled work rejected'))) + ).resolves.toBeUndefined(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(jest.mocked(captureException)).toHaveBeenCalledTimes(1); + const [error] = jest.mocked(captureException).mock.calls[0] ?? []; + expect((error as Error).message).toBe('scheduled work rejected'); + }); +}); diff --git a/apps/web/src/lib/analytics-outbox/capture.ts b/apps/web/src/lib/analytics-outbox/capture.ts new file mode 100644 index 0000000000..da2b904b4d --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/capture.ts @@ -0,0 +1,126 @@ +/** + * Best-effort server capture for cataloged accepted-phase analytics events. + * + * Catalog contract (P1-A-07a / DEC-05): terminal outcome events (`*_settled`) + * deliver only through the durable outbox via the ledger settle path (Wave 2, + * `packages/db/src/operation-ledger.ts`) — this helper excludes them at the + * type level via `AcceptedPhaseEventName` and never touches the + * `analytics_event_outbox` table. Accepted-phase events are best-effort: + * scheduled after the response is sent (`runAfterResponse`), awaited, bounded + * by `CAPTURE_TIMEOUT_MS`, never thrown to the caller, and Sentry-reported on + * failure. Capture is fire-and-forget by design; callers do not await it. + * + * `distinctId` is the identity channel, matching the cross-platform + * convention (mobile `identifyUser(email)` and the web provider identify by + * email). It is not an event property, so the DEC-05 property deny-list does + * not apply to it. + */ +import 'server-only'; + +import { captureException } from '@sentry/nextjs'; +import { after } from 'next/server'; + +import type { AcceptedPhaseEventName, AnalyticsEventMap } from '@kilocode/app-shared/analytics'; + +import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; +import PostHogClient from '@/lib/posthog'; + +/** Upper bound for a single accepted-phase capture attempt. */ +export const CAPTURE_TIMEOUT_MS = 2000; + +export type CaptureCatalogEventParams = { + /** Identity channel, not an event property (the user's email). */ + distinctId: string; + event: K; + properties: AnalyticsEventMap[K]; +}; + +/** + * Runs `work` after the response has been sent so serverless functions stay + * alive for the capture. In automated tests the work runs inline so jest can + * assert on it. Best-effort: a synchronous `after()` failure (called outside a + * request scope) or a rejection from the scheduled work promise is + * Sentry-reported here and never propagates to the caller or becomes an + * unhandled rejection. + */ +export async function runAfterResponse(work: () => Promise): Promise { + if (IS_IN_AUTOMATED_TEST) { + await work(); + return; + } + try { + // The scheduled promise must never reject unhandled; `work` already + // reports its own capture failures, this catch is the safety net. + after(() => { + void work().catch(reportCaptureError); + }); + } catch (error) { + reportCaptureError(error); + } +} + +/** Best-effort failure reporting for the post-response scheduling path. */ +function reportCaptureError(error: unknown): void { + captureException(error, { + tags: { source: 'analytics_capture_catalog_event' }, + }); +} + +/** + * Captures a cataloged accepted-phase event. `event` is restricted to + * accepted-phase names; terminal outcome events cannot be passed here (they + * must go through the ledger settle path). Best-effort: a failure is + * Sentry-reported and never reaches the caller. + */ +export function captureCatalogEvent( + params: CaptureCatalogEventParams +): void { + void runAfterResponse(() => captureAcceptedEvent(params)); +} + +async function captureAcceptedEvent( + params: CaptureCatalogEventParams +): Promise { + try { + await bounded( + Promise.resolve().then(() => sendToPostHog(params)), + CAPTURE_TIMEOUT_MS + ); + } catch (error) { + captureException(error, { + tags: { source: 'analytics_capture_catalog_event' }, + extra: { event: params.event }, + }); + } +} + +function sendToPostHog( + params: CaptureCatalogEventParams +): void { + PostHogClient().capture({ + distinctId: params.distinctId, + event: params.event, + properties: params.properties, + }); +} + +/** + * Resolves when `promise` settles or after `ms`, whichever comes first. A + * timeout releases the awaiting work without cancelling the inner promise; its + * eventual rejection is already handled by the attached callbacks. + */ +function bounded(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(undefined), ms); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); +} diff --git a/docs/analytics-event-catalog.md b/docs/analytics-event-catalog.md new file mode 100644 index 0000000000..d64e7bebd7 --- /dev/null +++ b/docs/analytics-event-catalog.md @@ -0,0 +1,175 @@ +# Analytics Event Catalog + +Authoritative catalog for the shared analytics event contract +(`packages/app-shared/src/analytics/`, P1-A-07a / DEC-05). The map and its +strict Zod schemas live in `event-map.ts`; this document records the +operational contract: identity, privacy, delivery, retention, deletion, and +the recorded exclusions. + +## Scope and authority + +- One source of truth for event names and payload shapes: + `ANALYTICS_EVENT_SCHEMAS` in `packages/app-shared/src/analytics/event-map.ts`. + The inferred `AnalyticsEventMap` types drive the typed capture helpers in + the mobile app (`apps/mobile/src/lib/analytics/posthog.ts`) and the web app + (`apps/web/src/lib/analytics-outbox/capture.ts`) and the durable-outbox + insert validation (`packages/db`, Wave 2). +- Every object schema is `.strict()`; values are restricted to stable enum + strings, integer counts, `duration_ms` integers, and booleans. The single + exception is `app_startup`, a bounded record of numeric timing marks. +- New event names are snake_case. Existing names are grandfathered verbatim in + `LEGACY_EVENT_NAMES` (frozen, no additions) — the kebab-case KiloClaw + onboarding names are locked to AppsFlyer dashboards. + +## Identity + +- `distinct_id` is the user's email, matching the existing cross-platform + convention: mobile `identifyUser(email)` and the web provider identify by + email, so one person is not double-counted across platforms. +- `distinct_id` is the identity channel, **not** an event property. The DEC-05 + property deny-list does not apply to it. +- The deterministic outbox `event_uuid` (Wave 2) is event identity for + at-least-once delivery and deduplication; it is a property carve-out in the + deny-list predicate because it is identity, not content. + +## Privacy deny-list (DEC-05) + +Prohibited in analytics payloads (enforced by schema and test): raw prompts, +message content, URLs, repository names, comments, emails, tokens, secrets, +transaction IDs, and resource IDs. The predicate (`privacy.ts`) rejects any +property key named `email`, `url`, `repo`, `prompt`, `content`, `token`, +`secret`, `transaction`, or any key ending in `_id` (except `event_uuid`). +Allowed: stable enum strings, integer counts, `duration_ms` integers, and +booleans. + +Two enforcement layers: + +- Compile time: the strict object schemas cannot carry a prohibited key. +- Runtime: `captureUncataloged` (mobile, AppsFlyer mirror only) and the + `app_startup` record payload redact prohibited keys before capture. + +## Delivery model + +| Phase | Event class | Delivery | Duplication semantics | Owner of truth | +|---|---|---|---|---| +| Accepted | Every non-`*_settled` event | Best-effort: client SDK direct (mobile) or `captureCatalogEvent` after the response (web). No outbox row. | Client SDKs and PostHog deduplicate where supported; best-effort may drop under transport failure. | Authoritative boundary of the underlying action (e.g. UI action, post-commit acceptance). Never an outcome authority. | +| Terminal | `*_settled` events | Durable outbox (Wave 2): insert in the same transaction as the ledger settle, drained by the web cron. | At-least-once. Deterministic `event_uuid` (UUIDv5) dedupes; duplicates possible only in the crash window between send and mark. | The authoritative acceptance boundary per domain (see event table). | + +`phase: 'accepted'` events are explicitly best-effort and never insert outbox +rows. Only terminal outcomes get durable delivery. `session_created` fires +after `prepareSession` returns and is cataloged as accepted-phase metadata, +not a terminal outcome. + +## Retention and deletion (DEC-01) + +- Ledger rows (`operation_ledgers`): expire 30 days after `admitted_at`. +- Outbox rows (`analytics_event_outbox`): delivered rows purge after 7 days, + terminal-failed after 30 days. The cron purges both and settles expired + non-terminal ledger rows as `failed` (`expired_unsettled`, no outbox event). +- User deletion: `softDeleteUser` in `apps/web/src/lib/user/index.ts` deletes + `operation_ledgers` rows by `kilo_user_id` and `analytics_event_outbox` + rows by `distinct_id` **before** email anonymization (the outbox + `distinct_id` is the email). +- Events delivered directly by client SDKs (accepted-phase) follow the + PostHog project retention policy; they carry no PII by schema. + +## Event table + +Columns: name — owner — business question — authoritative source boundary — +privacy class — delivery. "Best-effort (SDK)" means the mobile PostHog SDK +captures at the client action. "Best-effort (server)" means +`captureCatalogEvent` on the web. + +### Existing mobile events (grandfathered names and shapes) + +| Event | Owner | Business question | Source boundary | Privacy class | Delivery | +|---|---|---|---|---|---| +| `session_viewed` | Mobile app | Which sessions are opened, and how? | Client: session detail rendered with `via` known | Enums only (`surface`, `via`) | Best-effort (SDK) | +| `message_sent` | Mobile app | How many messages are sent per surface? | Client: send succeeded | Enums only (`surface`) | Best-effort (SDK) | +| `session_created` | Mobile app | How often is a cloud session created? | Client: `prepareSession` returned — accepted-phase metadata, not a terminal outcome | Literal enum (`surface`) | Best-effort (SDK) | +| `permission_responded` | Mobile app | How do users answer permission requests? | Client: response submitted | Enums only (`surface`, `response`) | Best-effort (SDK) | +| `question_answered` | Mobile app | How often do users answer vs skip questions? | Client: answer/reject submitted | Enum + boolean (`surface`, `skipped`) | Best-effort (SDK) | +| `conversation_created` | Mobile app | How often are KiloClaw conversations started? | Client: conversation created | Literal enum (`surface`) | Best-effort (SDK) | +| `instance_action` | Mobile app | Which instance lifecycle actions do users take? | Client: action issued | Enums only (`surface`, `action`) | Best-effort (SDK) | +| `feedback_submitted` | Mobile app | What is the feedback sentiment mix? | Client: feedback submitted | Enum only (`sentiment`) | Best-effort (SDK) | +| `organization_member_invited` | Mobile app | How often are members invited, and to which role? | Client: invite succeeded | Enum only (`role`) | Best-effort (SDK) | +| `kilo_pass_purchase_started` | Mobile app | How often do purchase flows start? | Client: purchase request issued | None (`{}`) | Best-effort (SDK) | +| `kilo_pass_purchase_completed` | Web + mobile | Purchase completion precedent (see Exclusions) | Post-commit acceptance boundary, untouched | None (`{}`) | Best-effort (server, existing call sites) | +| `kilo_pass_purchase_failed` | Mobile app | How often do purchase flows fail? | Client: purchase error | None (`{}`) | Best-effort (SDK) | +| `app_startup` | Mobile app | How long does cold start take per gate? | Client: first launch drain | Record of numeric marks + `outcome` enum; keys runtime-checked against the deny-list | Best-effort (SDK) | + +### KiloClaw onboarding events (legacy kebab-case, AppsFlyer-locked) + +Names are locked because they feed AppsFlyer dashboards; do not rename. +Captured through the AppsFlyer SDK and mirrored into PostHog via +`captureUncataloged`. + +| Event | Owner | Business question | Source boundary | Privacy class | Delivery | +|---|---|---|---|---|---| +| `onboarding-entered` | KiloClaw onboarding | Onboarding starts | Client: onboarding screen | None (`{}`) | AppsFlyer + best-effort mirror | +| `provision-requested` | KiloClaw onboarding | Provision requests | Client: provision issued | None (`{}`) | AppsFlyer + best-effort mirror | +| `provision-succeeded` | KiloClaw onboarding | Successful provisions | Client: provision success | None (`{}`) | AppsFlyer + best-effort mirror | +| `provision-failed` | KiloClaw onboarding | Provision failures by category | Client: provision failure | Enum only (`category`) | AppsFlyer + best-effort mirror | +| `access-required-shown` | KiloClaw onboarding | Access-blocked screens by subcase | Client: access-required UI shown | Enum only (`subcase`) | AppsFlyer + best-effort mirror | +| `completion-reached` | KiloClaw onboarding | Onboarding completion | Client: completion screen | None (`{}`) | AppsFlyer + best-effort mirror | +| `claw_weather_location_selected` | KiloClaw onboarding | Weather location selection | Client: location chosen | None (`{}`) | AppsFlyer + best-effort mirror | +| `claw_weather_location_skipped` | KiloClaw onboarding | Weather location skip | Client: location skipped | None (`{}`) | AppsFlyer + best-effort mirror | + +### AppsFlyer mirror + +| Event | Owner | Business question | Source boundary | Privacy class | Delivery | +|---|---|---|---|---|---| +| `login` | Auth (mobile) | Login funnel visibility in product analytics | Client: AppsFlyer auth mirror | None (`{}` today; dynamic properties redacted at runtime) | AppsFlyer + best-effort mirror | + +### Terminal outcome events (durable outbox, Wave 2) + +These are the only events the durable outbox may emit. They carry the DEC-05 +base fields (`source`, `surface`, `phase: 'terminal'`, `outcome`) and bounded +metric fields. Emitted at the authoritative acceptance boundary, never at HTTP +receipt. + +| Event | Owner | Business question | Source boundary | Privacy class | Delivery | +|---|---|---|---|---|---| +| `session_create_settled` | cloud-agent-next handler | Did cloud session creation settle, and how? | Durable DO registration + initial admission succeeded; allocation-failure stages; takeover reconcile | Enums + counts + `duration_ms` + boolean | Durable outbox | +| `pr_operation_settled` | github-pr-review router | Did the PR operation (merge/review/comment) settle? | GitHub committed response; ambiguity reconciled (or `unresolved`) | Enums + `duration_ms` | Durable outbox | +| `security_command_settled` | security-sync worker + web handler | Did the security command (sync/dismiss) settle? | Command status transition to terminal; pre-acceptance definitive failure | Enums + counts + `duration_ms` | Durable outbox | +| `organization_write_settled` | organization-members router | Did the member role change or removal settle? | Helper's committed transaction; takeover read-back | Enums only | Durable outbox | + +## Recorded exclusions + +- `kilo_pass_purchase_completed` precedent: post-commit acceptance boundary, + existing call sites, untouched by the outbox work. Cataloged as accepted + phase; not an outcome authority. +- Remote (CLI) session creation emits no `session_create_settled` in this + package: the authoritative boundary is the session-ingest Durable Object, + which has no Postgres path in scope. Duplicate-admission safety is still + delivered by the DO `mutationId` dedupe; terminal analytics for remote + creation is future work. +- `session_created` fires after `prepareSession` returns; it is accepted-phase + metadata, never a terminal outcome, and is not moved or renamed. +- `app_startup` payload stays a bounded record of numeric timing marks (the + single record-schema exception); its keys are checked against the deny-list + at runtime capture. +- `captureUncataloged` (mobile) is sanctioned for the AppsFlyer mirror path + only (`appsflyer.ts` `trackEvent`, which forwards arbitrary locked funnel + names). No other caller may use it; it applies consent, generation, and + privacy redaction. +- KiloClaw component call sites keep calling the typed `captureEvent`; their + event names have exact map entries and compile unedited. KiloClaw files are + never edited by the analytics contract. +- `login` is a raw string at the auth call site (AppsFlyer-mirrored); it is + cataloged with an empty schema because the mirror values are dynamic and + runtime-redacted. +- `interrupted` and `superseded` outcomes are structurally unused for the + Security domain in this package; the shared enum reserves them for other + domains. + +## Adding an event + +1. Add a snake_case name constant and a strict Zod schema to + `event-map.ts`; the map and the inferred `AnalyticsEventMap` extend + automatically. +2. Every new terminal outcome event carries the DEC-05 base fields. +3. Add the row to this catalog with owner, business question, source + boundary, privacy class, delivery, and duplication semantics. +4. Do not add to `LEGACY_EVENT_NAMES`; it is frozen. diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index d6e377ebaf..35f497e074 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -13,7 +13,8 @@ "./universal-links": "./src/universal-links/index.ts", "./opencode": "./src/opencode.gen.ts", "./images-schema": "./src/images-schema.ts", - "./cloud-agent": "./src/cloud-agent.ts" + "./cloud-agent": "./src/cloud-agent.ts", + "./analytics": "./src/analytics/index.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/app-shared/src/analytics/event-map.test.ts b/packages/app-shared/src/analytics/event-map.test.ts new file mode 100644 index 0000000000..e4a5a06eaa --- /dev/null +++ b/packages/app-shared/src/analytics/event-map.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { + ACCESS_REQUIRED_SHOWN_EVENT, + ANALYTICS_EVENT_SCHEMAS, + APP_STARTUP_EVENT, + CLAW_WEATHER_LOCATION_SELECTED_EVENT, + CLAW_WEATHER_LOCATION_SKIPPED_EVENT, + COMPLETION_REACHED_EVENT, + CONVERSATION_CREATED_EVENT, + FEEDBACK_SUBMITTED_EVENT, + INSTANCE_ACTION_EVENT, + KILO_PASS_PURCHASE_COMPLETED_EVENT, + KILO_PASS_PURCHASE_FAILED_EVENT, + KILO_PASS_PURCHASE_STARTED_EVENT, + LEGACY_EVENT_NAMES, + LOGIN_EVENT, + MESSAGE_SENT_EVENT, + ONBOARDING_ENTERED_EVENT, + ORGANIZATION_MEMBER_INVITED_EVENT, + ORGANIZATION_WRITE_SETTLED_EVENT, + PERMISSION_RESPONDED_EVENT, + PR_OPERATION_SETTLED_EVENT, + PROVISION_FAILED_EVENT, + PROVISION_REQUESTED_EVENT, + PROVISION_SUCCEEDED_EVENT, + QUESTION_ANSWERED_EVENT, + SECURITY_COMMAND_SETTLED_EVENT, + SESSION_CREATED_EVENT, + SESSION_CREATE_SETTLED_EVENT, + SESSION_VIEWED_EVENT, + TERMINAL_PHASE_EVENTS, + type AcceptedPhaseEventName, + type TerminalOutcomeEventName, +} from './event-map'; +import { isProhibitedPropertyKey, redactProhibitedProperties } from './privacy'; + +const ALL_EVENT_CONSTANTS = [ + SESSION_VIEWED_EVENT, + MESSAGE_SENT_EVENT, + SESSION_CREATED_EVENT, + PERMISSION_RESPONDED_EVENT, + QUESTION_ANSWERED_EVENT, + CONVERSATION_CREATED_EVENT, + INSTANCE_ACTION_EVENT, + FEEDBACK_SUBMITTED_EVENT, + ORGANIZATION_MEMBER_INVITED_EVENT, + KILO_PASS_PURCHASE_STARTED_EVENT, + KILO_PASS_PURCHASE_COMPLETED_EVENT, + KILO_PASS_PURCHASE_FAILED_EVENT, + APP_STARTUP_EVENT, + ONBOARDING_ENTERED_EVENT, + PROVISION_REQUESTED_EVENT, + PROVISION_SUCCEEDED_EVENT, + PROVISION_FAILED_EVENT, + ACCESS_REQUIRED_SHOWN_EVENT, + COMPLETION_REACHED_EVENT, + CLAW_WEATHER_LOCATION_SELECTED_EVENT, + CLAW_WEATHER_LOCATION_SKIPPED_EVENT, + LOGIN_EVENT, + SESSION_CREATE_SETTLED_EVENT, + PR_OPERATION_SETTLED_EVENT, + SECURITY_COMMAND_SETTLED_EVENT, + ORGANIZATION_WRITE_SETTLED_EVENT, +]; + +type ZodDefProbe = { + type?: string; + shape?: Record; + catchall?: { _def?: { type?: string } }; +}; + +function defOf(schema: z.ZodType): ZodDefProbe { + return (schema as unknown as { _def: ZodDefProbe })._def; +} + +describe('ANALYTICS_EVENT_SCHEMAS', () => { + it('covers every exported event constant', () => { + for (const name of ALL_EVENT_CONSTANTS) { + expect(ANALYTICS_EVENT_SCHEMAS, `missing schema for ${name}`).toHaveProperty(name); + } + expect(Object.keys(ANALYTICS_EVENT_SCHEMAS)).toHaveLength(ALL_EVENT_CONSTANTS.length); + }); + + it('defines every schema as a strict object', () => { + for (const [name, schema] of Object.entries(ANALYTICS_EVENT_SCHEMAS)) { + const def = defOf(schema); + expect(def.type, `${name} must be an object schema`).toBe('object'); + // zod v4 represents `.strict()` as a `never` catchall on the object. + expect(def.catchall?._def?.type, `${name} must use .strict()`).toBe('never'); + } + }); + + it('rejects unknown keys on every object schema', () => { + for (const [name, schema] of Object.entries(ANALYTICS_EVENT_SCHEMAS)) { + const def = defOf(schema); + if (def.type !== 'object' || !def.shape) { + continue; + } + const shape = def.shape as Record; + const valid = Object.fromEntries( + Object.entries(shape).map(([key, value]) => [key, sampleValue(value)]) + ); + expect(schema.safeParse(valid).success, `${name} valid payload`).toBe(true); + expect( + schema.safeParse({ ...valid, unexpected_key: 'x' }).success, + `${name} must reject unknown keys` + ).toBe(false); + } + }); +}); + +/** Builds a value each schema accepts, used to probe unknown-key rejection. */ +function sampleValue(schema: z.ZodType): unknown { + const def = defOf(schema); + switch (def.type) { + case 'enum': { + const entries = (schema as unknown as { _def: { entries?: Record } })._def + .entries; + return entries ? Object.values(entries)[0] : undefined; + } + case 'literal': + return (schema as unknown as { _def: { values?: readonly unknown[] } })._def.values?.[0]; + case 'boolean': + return true; + case 'number': + return 0; + case 'string': + return 'x'; + case 'optional': { + const inner = (schema as unknown as { _def: { innerType: z.ZodType } })._def.innerType; + return sampleValue(inner); + } + default: + throw new Error(`sampleValue does not know schema type ${String(def.type)}`); + } +} + +describe('event name rules', () => { + const SNAKE_CASE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/; + + it('uses snake_case for every event name outside the frozen legacy set', () => { + for (const name of Object.keys(ANALYTICS_EVENT_SCHEMAS)) { + if (LEGACY_EVENT_NAMES.has(name)) { + continue; + } + expect(name, `${name} must be snake_case`).toMatch(SNAKE_CASE); + } + }); + + it('freezes LEGACY_EVENT_NAMES to the kebab-case KiloClaw onboarding names', () => { + expect(LEGACY_EVENT_NAMES).toEqual( + new Set([ + ONBOARDING_ENTERED_EVENT, + PROVISION_REQUESTED_EVENT, + PROVISION_SUCCEEDED_EVENT, + PROVISION_FAILED_EVENT, + ACCESS_REQUIRED_SHOWN_EVENT, + COMPLETION_REACHED_EVENT, + CLAW_WEATHER_LOCATION_SELECTED_EVENT, + CLAW_WEATHER_LOCATION_SKIPPED_EVENT, + ]) + ); + }); +}); + +describe('phase classification', () => { + it('classifies every settled event as terminal and the rest as accepted', () => { + const terminal: TerminalOutcomeEventName[] = [...TERMINAL_PHASE_EVENTS]; + const accepted: AcceptedPhaseEventName[] = [ + SESSION_CREATED_EVENT, + KILO_PASS_PURCHASE_COMPLETED_EVENT, + APP_STARTUP_EVENT, + ]; + expect(terminal).toHaveLength(4); + for (const name of TERMINAL_PHASE_EVENTS) { + expect(ANALYTICS_EVENT_SCHEMAS).toHaveProperty(name); + } + // session_created is accepted-phase metadata, never a terminal outcome. + expect(TERMINAL_PHASE_EVENTS).not.toContain(SESSION_CREATED_EVENT); + expect(accepted.length).toBeGreaterThan(0); + }); + + it('gives every terminal schema the DEC-05 base fields', () => { + for (const name of TERMINAL_PHASE_EVENTS) { + const schema = ANALYTICS_EVENT_SCHEMAS[name]; + const shape = defOf(schema).shape ?? {}; + expect(Object.keys(shape), name).toEqual( + expect.arrayContaining(['source', 'surface', 'phase', 'outcome']) + ); + expect(schema.safeParse({ phase: 'accepted' }).success).toBe(false); + } + }); +}); + +describe('app_startup validation', () => { + const startupSchema = ANALYTICS_EVENT_SCHEMAS[APP_STARTUP_EVENT]; + + it('accepts the current takeStartupTimings() payload shape', () => { + expect( + startupSchema.safeParse({ outcome: 'app', auth_ready: 0, splash_hidden: 80 }).success + ).toBe(true); + expect( + startupSchema.safeParse({ + outcome: 'force-update', + auth_ready: 12, + fonts_ready: 30, + theme_ready: 40, + user_ready: 55, + consent_ready: 70, + splash_hidden: 85, + }).success + ).toBe(true); + }); + + it('rejects an invalid startup outcome', () => { + expect(startupSchema.safeParse({ outcome: 'invalid' }).success).toBe(false); + expect(startupSchema.safeParse({ outcome: 'APP' }).success).toBe(false); + expect(startupSchema.safeParse({ outcome: 'app', auth_ready: 12, email: 'raw' }).success).toBe( + false + ); + }); + + it('rejects unknown keys, including the privacy deny-list example', () => { + expect(startupSchema.safeParse({ email: 'raw', outcome: 'invalid' }).success).toBe(false); + expect(startupSchema.safeParse({ outcome: 'app', unexpected_key: 1 }).success).toBe(false); + }); + + it('rejects non-numeric timing values', () => { + expect(startupSchema.safeParse({ outcome: 'app', auth_ready: 'slow' }).success).toBe(false); + expect(startupSchema.safeParse({ outcome: 'app', splash_hidden: true }).success).toBe(false); + }); +}); + +describe('privacy deny-list', () => { + it('rejects prohibited property keys and allows event_uuid', () => { + for (const key of [ + 'email', + 'url', + 'repo', + 'prompt', + 'content', + 'token', + 'secret', + 'transaction', + 'session_id', + 'user_id', + 'repo_id', + 'stripe_invoice_id', + 'provider_transaction_id', + ]) { + expect(isProhibitedPropertyKey(key), key).toBe(true); + } + expect(isProhibitedPropertyKey('event_uuid')).toBe(false); + }); + + it('allows allowed enum, count, and duration keys', () => { + for (const key of [ + 'source', + 'surface', + 'phase', + 'outcome', + 'intent', + 'admission', + 'duration_ms', + 'repo_count', + 'error_count', + 'in_organization', + 'skipped', + 'sentiment', + 'role', + 'via', + ]) { + expect(isProhibitedPropertyKey(key), key).toBe(false); + } + }); + + it('walks every schema property key and finds none prohibited', () => { + for (const [name, schema] of Object.entries(ANALYTICS_EVENT_SCHEMAS)) { + const def = defOf(schema); + if (def.type !== 'object' || !def.shape) { + continue; + } + for (const key of Object.keys(def.shape)) { + expect(isProhibitedPropertyKey(key), `${name}.${key}`).toBe(false); + } + } + }); + + it('keeps the app_startup payload keys deny-list clean', () => { + const payload: Record = { + outcome: 'app', + auth_ready: 0, + fonts_ready: 12, + theme_ready: 20, + user_ready: 45, + consent_ready: 60, + splash_hidden: 80, + }; + for (const key of Object.keys(payload)) { + expect(isProhibitedPropertyKey(key), key).toBe(false); + } + expect(redactProhibitedProperties(payload)).toEqual(payload); + }); + + it('drops prohibited keys from an uncataloged runtime payload', () => { + const input = { surface: 'claw', email: 'a@b.co', session_id: 'x', ok_count: 1 }; + expect(redactProhibitedProperties(input)).toEqual({ surface: 'claw', ok_count: 1 }); + expect(input).toEqual({ surface: 'claw', email: 'a@b.co', session_id: 'x', ok_count: 1 }); + }); +}); diff --git a/packages/app-shared/src/analytics/event-map.ts b/packages/app-shared/src/analytics/event-map.ts new file mode 100644 index 0000000000..f1871d43b2 --- /dev/null +++ b/packages/app-shared/src/analytics/event-map.ts @@ -0,0 +1,315 @@ +/** + * Shared typed analytics event map and catalog contract (P1-A-07a / DEC-05). + * + * One strict Zod schema per event name. `AnalyticsEventMap` is inferred from + * the schemas and drives the typed capture helpers in the web and mobile apps + * and the durable-outbox insert validation (packages/db, Wave 2). + * + * Rules enforced here and by the unit tests: + * - Every object schema is `.strict()`: unknown keys fail. + * - Property values are restricted to enum strings, numbers, booleans. + * - `app_startup` is `.strict()` at runtime (bounded outcome enum and timing + * marks only) while its map type keeps the record shape so the mobile + * `takeStartupTimings()` payload compiles unchanged. + * - New catalog event names are snake_case. Existing event names are + * grandfathered verbatim in `LEGACY_EVENT_NAMES` (includes the kebab-case + * KiloClaw onboarding names, which are locked to AppsFlyer dashboards); + * the set is frozen — no additions. + * - Existing legacy event payloads keep their exact current shapes. They are + * never outcome authorities. `session_created` is accepted-phase metadata + * (fires after `prepareSession` returns), not a terminal outcome. + * - New terminal outcome events (`*_settled`) carry the DEC-05 base fields: + * `source`, `surface`, `phase: 'terminal'`, `outcome`, and bounded metric + * fields. + */ +import { z } from 'zod'; + +// ----- shared enums ------------------------------------------------------- + +export const ANALYTICS_SOURCES = ['mobile', 'web', 'server'] as const; +export const ANALYTICS_PHASES = ['terminal', 'accepted'] as const; +export const ANALYTICS_OUTCOMES = [ + 'completed', + 'failed', + 'no_op', + 'interrupted', + 'superseded', + 'ambiguous', +] as const; + +/** Legacy mobile surface values (existing payloads, unchanged). */ +export const ANALYTICS_SURFACES = ['claw', 'cloud-agent', 'remote-session'] as const; +export const SESSION_OPENED_VIA = ['push', 'app'] as const; +export const INSTANCE_ACTIONS = [ + 'destroy', + 'redeploy', + 'start', + 'stop', + 'restart_openclaw', +] as const; +export const PERMISSION_RESPONSES = ['once', 'always', 'reject'] as const; +export const FEEDBACK_SENTIMENTS = ['positive', 'negative'] as const; +export const ORGANIZATION_ROLES = ['owner', 'member', 'billing_manager'] as const; + +/** App cold-start outcome values (mirrors apps/mobile/src/lib/startup-timing.ts). */ +export const STARTUP_OUTCOMES = [ + 'app', + 'login', + 'consent', + 'force-update', + 'user-error', + 'consent-error', +] as const; + +/** KiloClaw onboarding enum values (grandfathered; mirrors onboarding-events.ts). */ +export const PROVISION_FAILED_CATEGORIES = ['lock', 'quarantine', 'access', 'generic'] as const; +export const ACCESS_REQUIRED_SUBCASES = [ + 'trial_expired', + 'subscription_canceled', + 'subscription_past_due', + 'quarantined', + 'multiple_current_conflict', + 'non_canonical_earlybird', +] as const; + +/** Terminal-outcome base fields (DEC-05). */ +export const SESSION_CREATE_FAILURE_STAGES = [ + 'report', + 'sandbox', + 'ownership_row', + 'registration', + 'initial_admission', +] as const; +export const SESSION_CREATE_ADMISSIONS = ['new', 'takeover'] as const; +export const PR_INTENTS = [ + 'merge', + 'submit_review', + 'create_review_comment', + 'reply_comment', +] as const; +export const SECURITY_INTENTS = ['manual_sync', 'dismiss_finding'] as const; +export const ORGANIZATION_INTENTS = ['member_role_change', 'member_remove'] as const; +export const PR_RECONCILE_RESULTS = [ + 'confirmed_completed', + 'confirmed_absent', + 'unresolved', +] as const; + +// ----- event name constants ------------------------------------------------ + +// Existing mobile events (grandfathered names, exact current shapes). +export const SESSION_VIEWED_EVENT = 'session_viewed'; +export const MESSAGE_SENT_EVENT = 'message_sent'; +export const SESSION_CREATED_EVENT = 'session_created'; +export const PERMISSION_RESPONDED_EVENT = 'permission_responded'; +export const QUESTION_ANSWERED_EVENT = 'question_answered'; +export const CONVERSATION_CREATED_EVENT = 'conversation_created'; +export const INSTANCE_ACTION_EVENT = 'instance_action'; +export const FEEDBACK_SUBMITTED_EVENT = 'feedback_submitted'; +export const ORGANIZATION_MEMBER_INVITED_EVENT = 'organization_member_invited'; +export const KILO_PASS_PURCHASE_STARTED_EVENT = 'kilo_pass_purchase_started'; +export const KILO_PASS_PURCHASE_COMPLETED_EVENT = 'kilo_pass_purchase_completed'; +export const KILO_PASS_PURCHASE_FAILED_EVENT = 'kilo_pass_purchase_failed'; +export const APP_STARTUP_EVENT = 'app_startup'; + +// KiloClaw onboarding events. Names are locked to AppsFlyer dashboards; the +// mobile constants live in apps/mobile/src/lib/analytics/onboarding-events.ts. +export const ONBOARDING_ENTERED_EVENT = 'onboarding-entered'; +export const PROVISION_REQUESTED_EVENT = 'provision-requested'; +export const PROVISION_SUCCEEDED_EVENT = 'provision-succeeded'; +export const PROVISION_FAILED_EVENT = 'provision-failed'; +export const ACCESS_REQUIRED_SHOWN_EVENT = 'access-required-shown'; +export const COMPLETION_REACHED_EVENT = 'completion-reached'; +export const CLAW_WEATHER_LOCATION_SELECTED_EVENT = 'claw_weather_location_selected'; +export const CLAW_WEATHER_LOCATION_SKIPPED_EVENT = 'claw_weather_location_skipped'; + +/** AppsFlyer-only mirrored event (raw string at the auth call site). */ +export const LOGIN_EVENT = 'login'; + +// New terminal outcome events (Wave 2 ledger settle path only). +export const SESSION_CREATE_SETTLED_EVENT = 'session_create_settled'; +export const PR_OPERATION_SETTLED_EVENT = 'pr_operation_settled'; +export const SECURITY_COMMAND_SETTLED_EVENT = 'security_command_settled'; +export const ORGANIZATION_WRITE_SETTLED_EVENT = 'organization_write_settled'; + +/** + * Grandfathered event names that are exempt from the snake_case rule. Frozen: + * the snake-case unit test asserts this exact set and forbids additions. + */ +export const LEGACY_EVENT_NAMES: ReadonlySet = new Set([ + ONBOARDING_ENTERED_EVENT, + PROVISION_REQUESTED_EVENT, + PROVISION_SUCCEEDED_EVENT, + PROVISION_FAILED_EVENT, + ACCESS_REQUIRED_SHOWN_EVENT, + COMPLETION_REACHED_EVENT, + CLAW_WEATHER_LOCATION_SELECTED_EVENT, + CLAW_WEATHER_LOCATION_SKIPPED_EVENT, +]); + +/** + * Terminal outcome events. Only these may be emitted by the durable outbox + * via the ledger settle path; accepted-phase events are best-effort delivery. + */ +export const TERMINAL_PHASE_EVENTS = [ + SESSION_CREATE_SETTLED_EVENT, + PR_OPERATION_SETTLED_EVENT, + SECURITY_COMMAND_SETTLED_EVENT, + ORGANIZATION_WRITE_SETTLED_EVENT, +] as const; + +export type TerminalPhaseEventName = (typeof TERMINAL_PHASE_EVENTS)[number]; + +// ----- schemas ------------------------------------------------------------- + +export const ANALYTICS_EVENT_SCHEMAS = { + // --- existing mobile events (payloads unchanged) --- + [SESSION_VIEWED_EVENT]: z + .object({ + surface: z.enum([...ANALYTICS_SURFACES]), + via: z.enum([...SESSION_OPENED_VIA]), + }) + .strict(), + [MESSAGE_SENT_EVENT]: z + .object({ + surface: z.enum([...ANALYTICS_SURFACES]), + }) + .strict(), + // Accepted-phase metadata, not a terminal outcome (recorded exclusion). + [SESSION_CREATED_EVENT]: z + .object({ + surface: z.literal('cloud-agent'), + }) + .strict(), + [PERMISSION_RESPONDED_EVENT]: z + .object({ + surface: z.enum([...ANALYTICS_SURFACES]), + response: z.enum([...PERMISSION_RESPONSES]), + }) + .strict(), + [QUESTION_ANSWERED_EVENT]: z + .object({ + surface: z.enum([...ANALYTICS_SURFACES]), + skipped: z.boolean(), + }) + .strict(), + [CONVERSATION_CREATED_EVENT]: z + .object({ + surface: z.literal('claw'), + }) + .strict(), + [INSTANCE_ACTION_EVENT]: z + .object({ + surface: z.literal('claw'), + action: z.enum([...INSTANCE_ACTIONS]), + }) + .strict(), + [FEEDBACK_SUBMITTED_EVENT]: z + .object({ + sentiment: z.enum([...FEEDBACK_SENTIMENTS]), + }) + .strict(), + [ORGANIZATION_MEMBER_INVITED_EVENT]: z + .object({ + role: z.enum([...ORGANIZATION_ROLES]), + }) + .strict(), + [KILO_PASS_PURCHASE_STARTED_EVENT]: z.object({}).strict(), + [KILO_PASS_PURCHASE_COMPLETED_EVENT]: z.object({}).strict(), + [KILO_PASS_PURCHASE_FAILED_EVENT]: z.object({}).strict(), + // app_startup: `.strict()` at runtime — only the documented outcome enum and + // the bounded numeric timing marks pass; unknown keys and nonnumeric timing + // values fail. The map type stays `Record` so the + // mobile `takeStartupTimings()` payload (and the web helper) compile + // unchanged. + [APP_STARTUP_EVENT]: z + .object({ + outcome: z.enum([...STARTUP_OUTCOMES]), + auth_ready: z.number().int().nonnegative().optional(), + fonts_ready: z.number().int().nonnegative().optional(), + theme_ready: z.number().int().nonnegative().optional(), + user_ready: z.number().int().nonnegative().optional(), + consent_ready: z.number().int().nonnegative().optional(), + splash_hidden: z.number().int().nonnegative().optional(), + }) + .strict() as z.ZodType>, + + // --- KiloClaw onboarding events (kebab-case, AppsFlyer-locked) --- + [ONBOARDING_ENTERED_EVENT]: z.object({}).strict(), + [PROVISION_REQUESTED_EVENT]: z.object({}).strict(), + [PROVISION_SUCCEEDED_EVENT]: z.object({}).strict(), + [PROVISION_FAILED_EVENT]: z + .object({ + category: z.enum([...PROVISION_FAILED_CATEGORIES]), + }) + .strict(), + [ACCESS_REQUIRED_SHOWN_EVENT]: z + .object({ + subcase: z.enum([...ACCESS_REQUIRED_SUBCASES]), + }) + .strict(), + [COMPLETION_REACHED_EVENT]: z.object({}).strict(), + [CLAW_WEATHER_LOCATION_SELECTED_EVENT]: z.object({}).strict(), + [CLAW_WEATHER_LOCATION_SKIPPED_EVENT]: z.object({}).strict(), + + // AppsFlyer-only mirrored auth event (no properties today). + [LOGIN_EVENT]: z.object({}).strict(), + + // --- new terminal outcome events (DEC-05 base fields) --- + [SESSION_CREATE_SETTLED_EVENT]: z + .object({ + source: z.enum([...ANALYTICS_SOURCES]), + surface: z.literal('session'), + phase: z.literal('terminal'), + creation_target: z.literal('cloud'), + outcome: z.enum([...ANALYTICS_OUTCOMES]), + admission: z.enum([...SESSION_CREATE_ADMISSIONS]), + failure_stage: z.enum([...SESSION_CREATE_FAILURE_STAGES]).optional(), + duration_ms: z.number().int().nonnegative(), + in_organization: z.boolean(), + }) + .strict(), + [PR_OPERATION_SETTLED_EVENT]: z + .object({ + source: z.enum([...ANALYTICS_SOURCES]), + surface: z.literal('pr'), + phase: z.literal('terminal'), + intent: z.enum([...PR_INTENTS]), + outcome: z.enum([...ANALYTICS_OUTCOMES]), + reconcile_result: z.enum([...PR_RECONCILE_RESULTS]).optional(), + duration_ms: z.number().int().nonnegative(), + }) + .strict(), + [SECURITY_COMMAND_SETTLED_EVENT]: z + .object({ + source: z.enum([...ANALYTICS_SOURCES]), + surface: z.literal('security'), + phase: z.literal('terminal'), + intent: z.enum([...SECURITY_INTENTS]), + outcome: z.enum([...ANALYTICS_OUTCOMES]), + repo_count: z.number().int().nonnegative().optional(), + error_count: z.number().int().nonnegative().optional(), + duration_ms: z.number().int().nonnegative(), + }) + .strict(), + [ORGANIZATION_WRITE_SETTLED_EVENT]: z + .object({ + source: z.enum([...ANALYTICS_SOURCES]), + surface: z.literal('organization'), + phase: z.literal('terminal'), + intent: z.enum([...ORGANIZATION_INTENTS]), + outcome: z.enum([...ANALYTICS_OUTCOMES]), + }) + .strict(), +} as const satisfies Record; + +/** Inferred event-name → payload type map. */ +export type AnalyticsEventMap = { + [K in keyof typeof ANALYTICS_EVENT_SCHEMAS]: z.infer<(typeof ANALYTICS_EVENT_SCHEMAS)[K]>; +}; + +/** Event names that deliver via the durable outbox (terminal outcomes only). */ +export type TerminalOutcomeEventName = Extract; + +/** Event names that deliver best-effort as accepted-phase metadata. */ +export type AcceptedPhaseEventName = Exclude; diff --git a/packages/app-shared/src/analytics/index.ts b/packages/app-shared/src/analytics/index.ts new file mode 100644 index 0000000000..cf57c1dc17 --- /dev/null +++ b/packages/app-shared/src/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './event-map'; +export * from './privacy'; diff --git a/packages/app-shared/src/analytics/privacy.ts b/packages/app-shared/src/analytics/privacy.ts new file mode 100644 index 0000000000..cba9f86465 --- /dev/null +++ b/packages/app-shared/src/analytics/privacy.ts @@ -0,0 +1,48 @@ +/** + * DEC-05 privacy deny-list for analytics payloads. + * + * Prohibited in analytics payloads (hard, enforced by schema and test): raw + * prompts, message content, URLs, repository names, comments, emails, tokens, + * secrets, transaction IDs, and resource IDs. Allowed: stable enum strings, + * integer counts, `duration_ms` integers, booleans. + * + * The predicate operates on *property keys*: a payload key that names a + * prohibited class of data is rejected. Resource IDs are any key ending in + * `_id`; the one carve-out is `event_uuid`, the deterministic event identity + * assigned by the durable outbox (identity, not content). + */ + +const PROHIBITED_PROPERTY_KEYS: ReadonlySet = new Set([ + 'email', + 'url', + 'repo', + 'prompt', + 'content', + 'token', + 'secret', + 'transaction', +] as const); + +/** True when a property key names a prohibited data class. */ +export function isProhibitedPropertyKey(key: string): boolean { + if (PROHIBITED_PROPERTY_KEYS.has(key)) { + return true; + } + return key.endsWith('_id') && key !== 'event_uuid'; +} + +/** + * Returns a copy of `properties` with every prohibited key removed. Used at + * runtime capture for record-shaped payloads whose keys a strict object + * schema cannot enumerate (`app_startup`) and for uncataloged dynamic-name + * events (the AppsFlyer mirror path). + */ +export function redactProhibitedProperties>(properties: T): T { + const safe: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (!isProhibitedPropertyKey(key)) { + safe[key] = value; + } + } + return safe as T; +} From f11f6b038ccaa0e6bbf8c20de50a18103a131fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 00:52:22 +0200 Subject: [PATCH 02/56] feat(db): add operation ledger and analytics outbox --- .../analytics-outbox.integration.test.ts | 527 + .../operation-ledger.integration.test.ts | 369 + apps/web/src/lib/user/index.test.ts | 78 + apps/web/src/lib/user/index.ts | 15 + packages/db/package.json | 4 +- packages/db/src/analytics-outbox.ts | 288 + ...operation_ledgers_and_analytics_outbox.sql | 39 + .../db/src/migrations/meta/0206_snapshot.json | 36631 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/operation-ledger.ts | 595 + packages/db/src/schema.ts | 92 + 11 files changed, 38644 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/analytics-outbox/analytics-outbox.integration.test.ts create mode 100644 apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts create mode 100644 packages/db/src/analytics-outbox.ts create mode 100644 packages/db/src/migrations/0206_operation_ledgers_and_analytics_outbox.sql create mode 100644 packages/db/src/migrations/meta/0206_snapshot.json create mode 100644 packages/db/src/operation-ledger.ts diff --git a/apps/web/src/lib/analytics-outbox/analytics-outbox.integration.test.ts b/apps/web/src/lib/analytics-outbox/analytics-outbox.integration.test.ts new file mode 100644 index 0000000000..e933941957 --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/analytics-outbox.integration.test.ts @@ -0,0 +1,527 @@ +/** + * Integration tests for the durable analytics outbox state machine (P2-A-04). + * + * Runs against the per-worker PostgreSQL test database migrated by + * `apps/web/src/tests/setup/workerSetup.ts`. Covers claim, delivery, + * backoff retry, terminal failure, stale-claim reclaim, retention purge, and + * the `expired_unsettled` ledger backstop. Rows are inserted directly here as + * test fixtures; production inserts flow only through the ledger settle + * helpers in `packages/db/src/operation-ledger.ts`. + */ +import { randomUUID } from 'crypto'; +import { eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/drizzle'; +import { + analytics_event_outbox, + operation_ledgers, + type AnalyticsEventOutboxRow, +} from '@kilocode/db/schema'; +import { + claimDueOutboxEvents, + markOutboxDelivered, + markOutboxRetry, + markOutboxFailed, + reclaimStaleSendingEvents, + purgeExpired, + OUTBOX_MAX_ATTEMPTS, + OUTBOX_INITIAL_RETRY_BACKOFF_MS, + OUTBOX_MAX_RETRY_BACKOFF_MS, + EXPIRED_UNSETTLED_OUTCOME_CODE, +} from '@kilocode/db/analytics-outbox'; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +async function insertOutboxRow( + overrides: Partial = {} +) { + const [row] = await db + .insert(analytics_event_outbox) + .values({ + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + ...overrides, + }) + .returning(); + if (!row) throw new Error('Outbox row insert returned no row'); + return row; +} + +/** Claims the single due pending row and returns it with its claim token. */ +async function claimFirstEvent(): Promise<{ row: AnalyticsEventOutboxRow; claimToken: string }> { + const [row] = await claimDueOutboxEvents(db, 10); + if (!row) throw new Error('claimDueOutboxEvents returned no row'); + if (!row.claimed_at) throw new Error('claimed event has no claimed_at'); + return { row, claimToken: row.claimed_at }; +} + +describe('analytics outbox (integration)', () => { + beforeEach(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + afterAll(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + it('claims only due pending rows, oldest first, and marks them sending', async () => { + const future = new Date(Date.now() + HOUR_MS).toISOString(); + const past = new Date(Date.now() - HOUR_MS).toISOString(); + const [firstDue, secondDue, notDue] = await db + .insert(analytics_event_outbox) + .values([ + { + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + created_at: past, + }, + { + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + created_at: new Date().toISOString(), + }, + { + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + next_attempt_at: future, + }, + ]) + .returning(); + if (!firstDue || !secondDue || !notDue) throw new Error('Outbox fixture insert failed'); + + const claimed = await claimDueOutboxEvents(db, 10); + expect(claimed.map(r => r.id).sort()).toEqual([firstDue.id, secondDue.id].sort()); + for (const row of claimed) { + expect(row.status).toBe('sending'); + expect(row.claimed_at).not.toBeNull(); + } + + const [stillPending] = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, notDue.id)); + expect(stillPending?.status).toBe('pending'); + }); + + it('respects the claim batch limit', async () => { + await insertOutboxRow(); + await insertOutboxRow(); + + const claimed = await claimDueOutboxEvents(db, 1); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.status).toBe('sending'); + + const pending = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.status, 'pending')); + expect(pending).toHaveLength(1); + }); + + it('marks a claimed event delivered and clears the claim and retry clock', async () => { + const inserted = await insertOutboxRow(); + const { claimToken } = await claimFirstEvent(); + + const delivered = await markOutboxDelivered(db, { + eventId: inserted.id, + claimedAt: claimToken, + }); + expect(delivered?.status).toBe('delivered'); + expect(delivered?.delivered_at).not.toBeNull(); + expect(delivered?.claimed_at).toBeNull(); + expect(delivered?.next_attempt_at).toBeNull(); + }); + + it('requeues a failed send with backoff and records the error', async () => { + const inserted = await insertOutboxRow(); + const { claimToken } = await claimFirstEvent(); + + const result = await markOutboxRetry(db, { + eventId: inserted.id, + claimedAt: claimToken, + error: 'posthog 500', + }); + expect(result?.outcome).toBe('retried'); + if (result?.outcome !== 'retried') return; + expect(result.row.status).toBe('pending'); + expect(result.row.attempts).toBe(1); + expect(result.row.last_error).toBe('posthog 500'); + expect(result.row.claimed_at).toBeNull(); + expect(result.row.next_attempt_at).not.toBeNull(); + // The first retry waits the 60-second initial backoff, not 120s (P2-A-04 regression). + const firstDelayMs = new Date(result.row.next_attempt_at as string).getTime() - Date.now(); + expect(firstDelayMs).toBeGreaterThan(OUTBOX_INITIAL_RETRY_BACKOFF_MS - 5_000); + expect(firstDelayMs).toBeLessThan(OUTBOX_INITIAL_RETRY_BACKOFF_MS + 5_000); + }); + + it('doubles the retry backoff on each later attempt and caps it at one hour', async () => { + // Second retry (old attempts = 1) waits 60s * 2^1 = 120s. + const secondRetry = await insertOutboxRow({ attempts: 1 }); + const secondClaim = await claimFirstEvent(); + const secondResult = await markOutboxRetry(db, { + eventId: secondRetry.id, + claimedAt: secondClaim.claimToken, + }); + expect(secondResult?.outcome).toBe('retried'); + if (secondResult?.outcome !== 'retried') return; + const secondDelayMs = + new Date(secondResult.row.next_attempt_at as string).getTime() - Date.now(); + expect(secondDelayMs).toBeGreaterThan(2 * OUTBOX_INITIAL_RETRY_BACKOFF_MS - 5_000); + expect(secondDelayMs).toBeLessThan(2 * OUTBOX_INITIAL_RETRY_BACKOFF_MS + 5_000); + + // Attempt 7 (old attempts = 6) would be 60s * 2^6 = 64min, so it caps at 60min. + const cappedRetry = await insertOutboxRow({ attempts: 6 }); + const cappedClaim = await claimFirstEvent(); + const cappedResult = await markOutboxRetry(db, { + eventId: cappedRetry.id, + claimedAt: cappedClaim.claimToken, + }); + expect(cappedResult?.outcome).toBe('retried'); + if (cappedResult?.outcome !== 'retried') return; + const cappedDelayMs = + new Date(cappedResult.row.next_attempt_at as string).getTime() - Date.now(); + expect(cappedDelayMs).toBeGreaterThan(OUTBOX_MAX_RETRY_BACKOFF_MS - 5_000); + expect(cappedDelayMs).toBeLessThan(OUTBOX_MAX_RETRY_BACKOFF_MS + 5_000); + }); + + it('fails a row terminally when retries reach the attempt cap', async () => { + const inserted = await insertOutboxRow({ attempts: OUTBOX_MAX_ATTEMPTS - 1 }); + const { claimToken } = await claimFirstEvent(); + + const result = await markOutboxRetry(db, { + eventId: inserted.id, + claimedAt: claimToken, + error: 'final failure', + }); + expect(result?.outcome).toBe('failed'); + if (result?.outcome !== 'failed') return; + expect(result.row.status).toBe('failed'); + expect(result.row.attempts).toBe(OUTBOX_MAX_ATTEMPTS); + expect(result.row.next_attempt_at).toBeNull(); + }); + + it('force-fails a claimed event for a definitive send error', async () => { + const inserted = await insertOutboxRow(); + const { claimToken } = await claimFirstEvent(); + + const failed = await markOutboxFailed(db, { + eventId: inserted.id, + claimedAt: claimToken, + error: 'invalid payload', + }); + expect(failed?.status).toBe('failed'); + expect(failed?.attempts).toBe(1); + expect(failed?.claimed_at).toBeNull(); + expect(failed?.last_error).toBe('invalid payload'); + }); + + it('ignores late old-sender marks after a stale reclaim and re-claim', async () => { + const inserted = await insertOutboxRow(); + const { claimToken: oldClaimToken } = await claimFirstEvent(); + + // Age the first claim past the stale window and reclaim it. + await db + .update(analytics_event_outbox) + .set({ claimed_at: new Date(Date.now() - 10 * 60 * 1000).toISOString() }) + .where(eq(analytics_event_outbox.id, inserted.id)); + const reclaimed = await reclaimStaleSendingEvents(db); + expect(reclaimed.map(r => r.id)).toContain(inserted.id); + + // A new drainer claims the event again with a fresh claim token. + const second = await claimFirstEvent(); + expect(second.row.id).toBe(inserted.id); + expect(second.claimToken).not.toBe(oldClaimToken); + + // The old sender's late marks must not touch the new sending claim. + expect( + await markOutboxDelivered(db, { eventId: inserted.id, claimedAt: oldClaimToken }) + ).toBeNull(); + expect( + await markOutboxFailed(db, { eventId: inserted.id, claimedAt: oldClaimToken, error: 'late' }) + ).toBeNull(); + expect( + await markOutboxRetry(db, { eventId: inserted.id, claimedAt: oldClaimToken, error: 'late' }) + ).toBeNull(); + + const [row] = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, inserted.id)); + expect(row?.status).toBe('sending'); + expect(row?.claimed_at).toBe(second.claimToken); + expect(row?.attempts).toBe(0); + }); + + it('a stale retry cannot requeue or fail a newer claim after reclaim and re-claim', async () => { + const inserted = await insertOutboxRow(); + const { claimToken: oldClaimToken } = await claimFirstEvent(); + + // The stale sender's claim is reclaimed and a new drainer re-claims the row. + await db + .update(analytics_event_outbox) + .set({ claimed_at: new Date(Date.now() - 10 * 60 * 1000).toISOString() }) + .where(eq(analytics_event_outbox.id, inserted.id)); + await reclaimStaleSendingEvents(db); + const second = await claimFirstEvent(); + expect(second.row.id).toBe(inserted.id); + expect(second.claimToken).not.toBe(oldClaimToken); + + // The stale retry is a no-op: it must not requeue or fail the newer claim. + expect( + await markOutboxRetry(db, { eventId: inserted.id, claimedAt: oldClaimToken, error: 'late' }) + ).toBeNull(); + + // The newer claim is untouched and still drives the event to delivery. + const [row] = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, inserted.id)); + expect(row?.status).toBe('sending'); + expect(row?.claimed_at).toBe(second.claimToken); + expect(row?.attempts).toBe(0); + expect(row?.next_attempt_at).toBeNull(); + + const delivered = await markOutboxDelivered(db, { + eventId: inserted.id, + claimedAt: second.claimToken, + }); + expect(delivered?.status).toBe('delivered'); + }); + + it('a stale retry cannot requeue a delivered terminal state', async () => { + const inserted = await insertOutboxRow(); + const { claimToken } = await claimFirstEvent(); + + // The row is delivered before the sender's retry arrives. + const delivered = await markOutboxDelivered(db, { + eventId: inserted.id, + claimedAt: claimToken, + }); + expect(delivered?.status).toBe('delivered'); + + expect( + await markOutboxRetry(db, { eventId: inserted.id, claimedAt: claimToken, error: 'late' }) + ).toBeNull(); + + const [row] = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, inserted.id)); + expect(row?.status).toBe('delivered'); + expect(row?.attempts).toBe(0); + expect(row?.next_attempt_at).toBeNull(); + }); + + it('does not overwrite a delivered terminal state with a late mark', async () => { + const inserted = await insertOutboxRow(); + const { claimToken } = await claimFirstEvent(); + + const delivered = await markOutboxDelivered(db, { + eventId: inserted.id, + claimedAt: claimToken, + }); + expect(delivered?.status).toBe('delivered'); + + // Replayed marks from the now-finished sender are all no-ops. + expect( + await markOutboxDelivered(db, { eventId: inserted.id, claimedAt: claimToken }) + ).toBeNull(); + expect(await markOutboxRetry(db, { eventId: inserted.id, claimedAt: claimToken })).toBeNull(); + expect(await markOutboxFailed(db, { eventId: inserted.id, claimedAt: claimToken })).toBeNull(); + + const [row] = await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, inserted.id)); + expect(row?.status).toBe('delivered'); + expect(row?.attempts).toBe(0); + }); + + it('reclaims only sending rows whose claim is older than the stale window', async () => { + const stale = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const recent = new Date().toISOString(); + const [staleRow, freshRow] = await db + .insert(analytics_event_outbox) + .values([ + { + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + status: 'sending', + claimed_at: stale, + }, + { + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + status: 'sending', + claimed_at: recent, + }, + ]) + .returning(); + if (!staleRow || !freshRow) throw new Error('Outbox fixture insert failed'); + + const reclaimed = await reclaimStaleSendingEvents(db); + expect(reclaimed.map(r => r.id)).toContain(staleRow.id); + expect(reclaimed.map(r => r.id)).not.toContain(freshRow.id); + + const [afterStale, afterFresh] = await Promise.all([ + db.select().from(analytics_event_outbox).where(eq(analytics_event_outbox.id, staleRow.id)), + db.select().from(analytics_event_outbox).where(eq(analytics_event_outbox.id, freshRow.id)), + ]); + expect(afterStale[0]?.status).toBe('pending'); + expect(afterStale[0]?.claimed_at).toBeNull(); + expect(afterFresh[0]?.status).toBe('sending'); + }); + + it('purges delivered rows after 7 days and failed rows after 30 days', async () => { + const oldDelivered = await insertOutboxRow({ + status: 'delivered', + delivered_at: new Date(Date.now() - 8 * DAY_MS).toISOString(), + }); + const recentDelivered = await insertOutboxRow({ + status: 'delivered', + delivered_at: new Date().toISOString(), + }); + const oldFailed = await insertOutboxRow({ + status: 'failed', + created_at: new Date(Date.now() - 31 * DAY_MS).toISOString(), + }); + const recentFailed = await insertOutboxRow({ status: 'failed' }); + + const result = await purgeExpired(db); + expect(result.outboxDeliveredPurged).toBe(1); + expect(result.outboxFailedPurged).toBe(1); + + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, oldDelivered.id)) + ).toHaveLength(0); + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, recentDelivered.id)) + ).toHaveLength(1); + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, oldFailed.id)) + ).toHaveLength(0); + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, recentFailed.id)) + ).toHaveLength(1); + }); + + it('settles expired non-terminal ledger rows as failed with expired_unsettled', async () => { + const now = new Date(); + const leaseExpiresAt = new Date(now.getTime() + HOUR_MS).toISOString(); + const expiredAt = new Date(now.getTime() - DAY_MS).toISOString(); + const futureExpiresAt = new Date(now.getTime() + DAY_MS).toISOString(); + + const [expiredAdmitted, expiredReconcile, future] = await db + .insert(operation_ledgers) + .values([ + { + operation_key: 'expired-admitted', + domain: 'session', + intent: 'create', + kilo_user_id: 'backstop-user', + taxonomy: 'safe-retry', + status: 'admitted', + lease_expires_at: leaseExpiresAt, + expires_at: expiredAt, + }, + { + operation_key: 'expired-reconcile', + domain: 'session', + intent: 'create', + kilo_user_id: 'backstop-user', + taxonomy: 'safe-retry', + status: 'reconcile_pending', + lease_expires_at: leaseExpiresAt, + expires_at: expiredAt, + }, + { + operation_key: 'future', + domain: 'session', + intent: 'create', + kilo_user_id: 'backstop-user', + taxonomy: 'safe-retry', + status: 'admitted', + lease_expires_at: leaseExpiresAt, + expires_at: futureExpiresAt, + }, + ]) + .returning(); + if (!expiredAdmitted || !expiredReconcile || !future) { + throw new Error('Ledger fixture insert failed'); + } + + const result = await purgeExpired(db); + expect(result.expiredUnsettledLedgerSettled).toBe(2); + + const [settledAdmitted, settledReconcile, untouched] = await Promise.all([ + db.select().from(operation_ledgers).where(eq(operation_ledgers.id, expiredAdmitted.id)), + db.select().from(operation_ledgers).where(eq(operation_ledgers.id, expiredReconcile.id)), + db.select().from(operation_ledgers).where(eq(operation_ledgers.id, future.id)), + ]); + expect(settledAdmitted[0]?.status).toBe('failed'); + expect(settledAdmitted[0]?.outcome_code).toBe(EXPIRED_UNSETTLED_OUTCOME_CODE); + expect(settledAdmitted[0]?.settled_at).not.toBeNull(); + expect(settledReconcile[0]?.status).toBe('failed'); + expect(settledReconcile[0]?.outcome_code).toBe(EXPIRED_UNSETTLED_OUTCOME_CODE); + expect(untouched[0]?.status).toBe('admitted'); + }); + + it('does not settle terminal ledger rows in the backstop', async () => { + const now = new Date(); + const leaseExpiresAt = new Date(now.getTime() + HOUR_MS).toISOString(); + const expiredAt = new Date(now.getTime() - DAY_MS).toISOString(); + + const [terminal] = await db + .insert(operation_ledgers) + .values({ + operation_key: 'already-settled', + domain: 'session', + intent: 'create', + kilo_user_id: 'backstop-user', + taxonomy: 'safe-retry', + status: 'completed', + outcome_code: 'ok', + settled_at: new Date(now.getTime() - HOUR_MS).toISOString(), + lease_expires_at: leaseExpiresAt, + expires_at: expiredAt, + }) + .returning(); + if (!terminal) throw new Error('Ledger fixture insert failed'); + + const result = await purgeExpired(db); + expect(result.expiredUnsettledLedgerSettled).toBe(0); + + const [row] = await db + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.id, terminal.id)); + expect(row?.status).toBe('completed'); + }); +}); diff --git a/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts new file mode 100644 index 0000000000..1f05a8feb6 --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts @@ -0,0 +1,369 @@ +/** + * Integration tests for the shared operation ledger (P1-A-08a / DEC-01). + * + * Runs against the per-worker PostgreSQL test database migrated by + * `apps/web/src/tests/setup/workerSetup.ts`. Covers the ledger state machine: + * concurrent same-key admit, duplicate replay, double settle, atomic + * settle-plus-outbox, canonical-result bound, expiration, lease takeover, + * reconcile-pending, deterministic event UUID, progress, and provider ref. + */ +import { randomUUID } from 'crypto'; +import { and, eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/drizzle'; +import { analytics_event_outbox, operation_ledgers } from '@kilocode/db/schema'; +import type { AnalyticsEventMap } from '@kilocode/app-shared/analytics'; +import { + admitOperation, + settleOperation, + markReconcilePending, + recordOperationProgress, + setOperationProviderRef, + computeEventUuid, + CanonicalResultTooLargeError, + OutboxEventValidationError, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; + +const SESSION_DOMAIN = 'session' as const; + +function terminalSessionEvent( + properties?: Partial +): OutboxEventInput { + return { + eventName: 'session_create_settled', + distinctId: 'user@example.com', + properties: { + source: 'server', + surface: 'session', + phase: 'terminal', + creation_target: 'cloud', + outcome: 'completed', + admission: 'new', + duration_ms: 120, + in_organization: false, + ...properties, + }, + }; +} + +async function admitSession(userId = 'ledger-user', operationKey: string = randomUUID()) { + return admitOperation(db, { + userId, + domain: SESSION_DOMAIN, + intent: 'create', + operationKey, + taxonomy: 'safe-retry', + leaseSeconds: 60, + }); +} + +describe('operation ledger (integration)', () => { + beforeEach(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + afterAll(async () => { + await db.delete(analytics_event_outbox).where(sql`true`); + await db.delete(operation_ledgers).where(sql`true`); + }); + + it('admits a fresh operation and settles it to a terminal status', async () => { + const admitted = await admitSession(); + expect(admitted.admission).toBe('admitted'); + if (admitted.admission !== 'admitted') return; + + const settled = await settleOperation(db, { + rowId: admitted.row.id, + status: 'completed', + outcomeCode: 'ok', + }); + expect(settled.settled).toBe(true); + if (!settled.settled) return; + expect(settled.row.status).toBe('completed'); + expect(settled.row.outcome_code).toBe('ok'); + expect(settled.row.settled_at).not.toBeNull(); + }); + + it('produces exactly one winner under concurrent same-key admits', async () => { + const results = await Promise.all( + Array.from({ length: 10 }, () => + admitOperation(db, { + userId: 'concurrent-user', + domain: SESSION_DOMAIN, + intent: 'create', + operationKey: 'concurrent-key', + taxonomy: 'safe-retry', + leaseSeconds: 60, + }) + ) + ); + + const winners = results.filter(r => r.admission === 'admitted'); + expect(winners).toHaveLength(1); + expect(results.filter(r => r.admission === 'duplicate_in_flight')).toHaveLength( + results.length - 1 + ); + + const rows = await db + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.kilo_user_id, 'concurrent-user')); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe('admitted'); + }); + + it('reports duplicate_settled on replay after a terminal settle', async () => { + const admitted = await admitSession('replay-user'); + if (admitted.admission !== 'admitted') return; + + await settleOperation(db, { rowId: admitted.row.id, status: 'completed' }); + + const replay = await admitSession('replay-user', admitted.row.operation_key); + expect(replay.admission).toBe('duplicate_settled'); + if (replay.admission !== 'duplicate_settled') return; + expect(replay.row.id).toBe(admitted.row.id); + }); + + it('reports duplicate_in_flight while the lease is live', async () => { + const first = await admitSession('in-flight-user'); + if (first.admission !== 'admitted') return; + + const second = await admitSession('in-flight-user', first.row.operation_key); + expect(second.admission).toBe('duplicate_in_flight'); + }); + + it('treats a second settle as a no-op', async () => { + const admitted = await admitSession('double-settle-user'); + if (admitted.admission !== 'admitted') return; + + const first = await settleOperation(db, { + rowId: admitted.row.id, + status: 'failed', + outcomeCode: 'err', + }); + expect(first.settled).toBe(true); + + const second = await settleOperation(db, { + rowId: admitted.row.id, + status: 'completed', + outcomeCode: 'ok', + }); + expect(second.settled).toBe(false); + if (!second.row) return; + expect(second.row.status).toBe('failed'); + expect(second.row.outcome_code).toBe('err'); + }); + + it('writes the outbox row atomically with the deterministic event uuid', async () => { + const admitted = await admitSession('outbox-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const settled = await settleOperation(db, { + rowId, + status: 'completed', + outboxEvent: terminalSessionEvent(), + }); + expect(settled.settled).toBe(true); + + const expectedUuid = await computeEventUuid(rowId, 'session_create_settled'); + const outboxRows = await db.select().from(analytics_event_outbox); + expect(outboxRows).toHaveLength(1); + expect(outboxRows[0]?.event_uuid).toBe(expectedUuid); + expect(outboxRows[0]?.distinct_id).toBe('user@example.com'); + expect(outboxRows[0]?.status).toBe('pending'); + expect(outboxRows[0]?.attempts).toBe(0); + expect(outboxRows[0]?.properties).toMatchObject({ outcome: 'completed' }); + }); + + it('rolls back the settle when the outbox event fails validation', async () => { + const admitted = await admitSession('rollback-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const invalidEvent = terminalSessionEvent({ duration_ms: -5 }); + + await expect( + settleOperation(db, { rowId, status: 'completed', outboxEvent: invalidEvent }) + ).rejects.toBeInstanceOf(OutboxEventValidationError); + + const [row] = await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, rowId)); + expect(row?.status).toBe('admitted'); + expect(await db.select().from(analytics_event_outbox)).toHaveLength(0); + }); + + it('emits only one outbox event per (ledger row, event name)', async () => { + const admitted = await admitSession('dedupe-event-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + await markReconcilePending(db, { + rowId, + outboxEvent: terminalSessionEvent({ outcome: 'ambiguous' }), + }); + await settleOperation(db, { + rowId, + status: 'completed', + outboxEvent: terminalSessionEvent(), + }); + + const outboxRows = await db.select().from(analytics_event_outbox); + expect(outboxRows).toHaveLength(1); + expect(outboxRows[0]?.properties).toMatchObject({ outcome: 'ambiguous' }); + }); + + it('rejects a canonical_result over the serialized bound and leaves the row admitted', async () => { + const admitted = await admitSession('bound-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const oversized: Record = { pad: 'x'.repeat(5000) }; + await expect( + settleOperation(db, { rowId, status: 'completed', canonicalResult: oversized }) + ).rejects.toBeInstanceOf(CanonicalResultTooLargeError); + + // The bound applies to the merged result, so progress plus a settle can + // also exceed it. + await recordOperationProgress(db, rowId, { pad: 'y'.repeat(3000) }); + await expect( + settleOperation(db, { + rowId, + status: 'completed', + canonicalResult: { pad2: 'z'.repeat(2000) }, + }) + ).rejects.toBeInstanceOf(CanonicalResultTooLargeError); + + const [row] = await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, rowId)); + expect(row?.status).toBe('admitted'); + }); + + it('rejects oversized progress merges atomically and preserves prior canonical result', async () => { + const admitted = await admitSession('progress-bound-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + await recordOperationProgress(db, rowId, { phase: 'allocated', id: 'abc' }); + + const oversized: Record = { pad: 'x'.repeat(5000) }; + await expect(recordOperationProgress(db, rowId, oversized)).rejects.toBeInstanceOf( + CanonicalResultTooLargeError + ); + + // The rejected merge must leave the row admitted with the prior result. + const [row] = await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, rowId)); + expect(row?.status).toBe('admitted'); + expect(row?.canonical_result).toMatchObject({ phase: 'allocated', id: 'abc' }); + expect(row?.canonical_result).not.toHaveProperty('pad'); + }); + + it('deletes an expired row and re-admits it fresh', async () => { + const admitted = await admitSession('expire-user'); + if (admitted.admission !== 'admitted') return; + const oldId = admitted.row.id; + + await db + .update(operation_ledgers) + .set({ expires_at: '2020-01-01T00:00:00.000Z' }) + .where(eq(operation_ledgers.id, oldId)); + + const replay = await admitSession('expire-user', admitted.row.operation_key); + expect(replay.admission).toBe('admitted'); + if (replay.admission !== 'admitted') return; + expect(replay.row.id).not.toBe(oldId); + + const rows = await db + .select() + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.kilo_user_id, 'expire-user'), + eq(operation_ledgers.operation_key, admitted.row.operation_key) + ) + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(replay.row.id); + }); + + it('takes over an expired-lease admitted row with a renewed lease', async () => { + const admitted = await admitSession('takeover-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + await db + .update(operation_ledgers) + .set({ lease_expires_at: '2020-01-01T00:00:00.000Z' }) + .where(eq(operation_ledgers.id, rowId)); + + const takeover = await admitSession('takeover-user', admitted.row.operation_key); + expect(takeover.admission).toBe('takeover'); + if (takeover.admission !== 'takeover') return; + expect(takeover.row.id).toBe(rowId); + expect(new Date(takeover.row.lease_expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + + it('transitions to reconcile_pending and re-admit reports duplicate_reconcile_pending', async () => { + const admitted = await admitSession('reconcile-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const reconciled = await markReconcilePending(db, { rowId }); + expect(reconciled?.status).toBe('reconcile_pending'); + + const replay = await admitSession('reconcile-user', admitted.row.operation_key); + expect(replay.admission).toBe('duplicate_reconcile_pending'); + + const settled = await settleOperation(db, { rowId, status: 'completed' }); + expect(settled.settled).toBe(true); + + // A reconcile after a terminal settle is a no-op that returns the row. + const lateReconcile = await markReconcilePending(db, { rowId }); + expect(lateReconcile?.status).toBe('completed'); + }); + + it('merges recordOperationProgress into canonical_result while admitted', async () => { + const admitted = await admitSession('progress-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const first = await recordOperationProgress(db, rowId, { phase: 'allocated', id: 'abc' }); + expect(first?.canonical_result).toMatchObject({ phase: 'allocated', id: 'abc' }); + + const second = await recordOperationProgress(db, rowId, { step: 2 }); + expect(second?.canonical_result).toMatchObject({ phase: 'allocated', id: 'abc', step: 2 }); + + await settleOperation(db, { + rowId, + status: 'completed', + canonicalResult: { final: true }, + }); + const [row] = await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, rowId)); + expect(row?.canonical_result).toMatchObject({ phase: 'allocated', step: 2, final: true }); + + // Progress after a terminal settle must not touch the row. + expect(await recordOperationProgress(db, rowId, { late: true })).toBeNull(); + }); + + it('overwrites the provider ref', async () => { + const admitted = await admitSession('provider-ref-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + const updated = await setOperationProviderRef(db, { rowId, providerRef: 'prov-1' }); + expect(updated?.provider_ref).toBe('prov-1'); + + const cleared = await setOperationProviderRef(db, { rowId, providerRef: null }); + expect(cleared?.provider_ref).toBeNull(); + }); + + it('computes a deterministic UUIDv5 per (rowId, eventName)', async () => { + const rowId = randomUUID(); + const first = await computeEventUuid(rowId, 'session_create_settled'); + const second = await computeEventUuid(rowId, 'session_create_settled'); + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + const other = await computeEventUuid(rowId, 'pr_operation_settled'); + expect(other).not.toBe(first); + }); +}); diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index 3f9bf3ff7e..9a6eb6c827 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -113,6 +113,8 @@ import { mcp_gateway_oauth_clients, mcp_gateway_oauth_grants, deployments_ephemeral, + operation_ledgers, + analytics_event_outbox, } from '@kilocode/db/schema'; import { eq, count, sql } from 'drizzle-orm'; @@ -166,6 +168,8 @@ describe('User', () => { // Shared cleanup for all tests in this suite to prevent data pollution afterEach(async () => { await db.delete(deployments_ephemeral); + await db.delete(operation_ledgers); + await db.delete(analytics_event_outbox); await db.delete(user_auth_provider); await db.delete(user_affiliate_attributions); await db.delete(user_affiliate_events); @@ -644,6 +648,80 @@ describe('User', () => { }); describe('softDeleteUser', () => { + it('deletes operation ledger rows by user id and analytics outbox rows by the original email', async () => { + const user = await insertTestUser({ google_user_email: 'ledger-user@example.com' }); + const otherUser = await insertTestUser(); + + const now = new Date(); + const leaseExpiresAt = new Date(now.getTime() + 60_000).toISOString(); + const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + const [userLedger, otherLedger] = await db + .insert(operation_ledgers) + .values([ + { + operation_key: 'op-1', + domain: 'session', + intent: 'create', + kilo_user_id: user.id, + taxonomy: 'safe-retry', + lease_expires_at: leaseExpiresAt, + expires_at: expiresAt, + }, + { + operation_key: 'op-2', + domain: 'session', + intent: 'create', + kilo_user_id: otherUser.id, + taxonomy: 'safe-retry', + lease_expires_at: leaseExpiresAt, + expires_at: expiresAt, + }, + ]) + .returning(); + + const [userOutbox, otherOutbox] = await db + .insert(analytics_event_outbox) + .values([ + { + event_uuid: crypto.randomUUID(), + event_name: 'session_create_settled', + distinct_id: user.google_user_email, + properties: { source: 'server' }, + }, + { + event_uuid: crypto.randomUUID(), + event_name: 'session_create_settled', + distinct_id: otherUser.google_user_email, + properties: { source: 'server' }, + }, + ]) + .returning(); + if (!userLedger || !otherLedger || !userOutbox || !otherOutbox) { + throw new Error('Failed to seed ledger or outbox rows'); + } + + await softDeleteUser(user.id); + + expect( + await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, userLedger.id)) + ).toHaveLength(0); + expect( + await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, otherLedger.id)) + ).toHaveLength(1); + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, userOutbox.id)) + ).toHaveLength(0); + expect( + await db + .select() + .from(analytics_event_outbox) + .where(eq(analytics_event_outbox.id, otherOutbox.id)) + ).toHaveLength(1); + }); + it('anonymizes recommendation dismissal actor references', async () => { const organizationOwner = await insertTestUser(); const dismissingUser = await insertTestUser(); diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index c29f01e835..d6d4fa4629 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -102,6 +102,8 @@ import { coding_plan_availability_intents, coding_plan_subscriptions, deployments_ephemeral, + operation_ledgers, + analytics_event_outbox, } from '@kilocode/db/schema'; import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm'; import { allow_fake_login, IS_DEVELOPMENT } from '@/lib/constants'; @@ -991,6 +993,9 @@ export async function assertUserCanBeSoftDeleted(userId: string): Promise * user_github_app_tokens, kiloclaw_instances/inbound_email_aliases/access_codes, * user_period_cache, kilo_pass_scheduled_changes, coding_plan_availability_intents, * user_notification_preferences) + * - operation_ledgers (per-intent dedupe rows, keyed by kilo_user_id) + * - analytics_event_outbox (pending analytics delivery rows, keyed by the + * user's email as distinct_id) * - kiloclaw_instances.admin_size_override JSONB (contains admin actorEmail * + free-form reason; cleared on the deleted user's retained destroyed * instances, AND on any other instances where this user was the admin @@ -1040,6 +1045,16 @@ export async function softDeleteUser(userId: string) { .delete(security_finding_notifications) .where(eq(security_finding_notifications.recipient_user_id, userId)); + // ── 0b. Operation ledger and analytics outbox ──────────────────────── + // Ledger rows are keyed by kilo_user_id; delete them so the dedupe + // identity dies with the account. Outbox rows are keyed by the user's + // email as distinct_id — delete by the original email captured before + // the user row is anonymized below. + await tx.delete(operation_ledgers).where(eq(operation_ledgers.kilo_user_id, userId)); + await tx + .delete(analytics_event_outbox) + .where(eq(analytics_event_outbox.distinct_id, originalEmail)); + // ── 1. Anonymize the user row ──────────────────────────────────────── await tx .update(kilocode_users) diff --git a/packages/db/package.json b/packages/db/package.json index d33ea3f679..9355edf499 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -15,7 +15,9 @@ "./kiloclaw-commit-retirement": "./src/kiloclaw-commit-retirement.ts", "./kiloclaw-organization-trial-expiry-candidates": "./src/kiloclaw-organization-trial-expiry-candidates.ts", "./client": "./src/client.ts", - "./user-soft-delete": "./src/user-soft-delete.ts" + "./user-soft-delete": "./src/user-soft-delete.ts", + "./operation-ledger": "./src/operation-ledger.ts", + "./analytics-outbox": "./src/analytics-outbox.ts" }, "dependencies": { "@kilocode/app-shared": "workspace:*", diff --git a/packages/db/src/analytics-outbox.ts b/packages/db/src/analytics-outbox.ts new file mode 100644 index 0000000000..b32d24001d --- /dev/null +++ b/packages/db/src/analytics-outbox.ts @@ -0,0 +1,288 @@ +/** + * Durable analytics outbox state machine (P2-A-04). + * + * Modeled on `user_affiliate_events` + `dispatchQueuedAffiliateEvents` + * (adapted, not imported). Rows are inserted ONLY by the operation-ledger + * settle helpers in `operation-ledger.ts`; this module moves rows through the + * delivery states: + * + * - `pending` → (claim) → `sending` → (delivered) → `delivered` + * - `sending` → (send error) → backoff retry → `pending` with `next_attempt_at` + * - `pending` → ... after `OUTBOX_MAX_ATTEMPTS` attempts → `failed` + * - `sending` claims older than `OUTBOX_STALE_SENDING_WINDOW_MS` → reclaimed to `pending` + * + * Delivery marks (`markOutboxDelivered`, `markOutboxRetry`, `markOutboxFailed`) + * are fenced on the claim: each takes the `claimed_at` token returned by the + * claim and updates only while the row is still that `sending` claim. A late + * mark from a sender whose claim was reclaimed and re-claimed is a no-op. + * + * `purgeExpired` enforces DEC-01 retention (delivered rows after 7 days, + * failed rows after 30 days) and runs the ledger backstop: non-terminal ledger + * rows past `expires_at` settle as `failed` with `outcome_code: + * 'expired_unsettled'` (operational residue, never a user outcome event). + * + * Delivery is at-least-once; duplicates are possible only in the crash window + * between send and mark, deduplicated by PostHog on the deterministic + * `event_uuid` where supported. + */ +import { and, eq, inArray, sql } from 'drizzle-orm'; + +import type { LedgerDatabase } from './operation-ledger'; +import { OPERATION_NON_TERMINAL_STATUSES } from './operation-ledger'; +import { analytics_event_outbox, operation_ledgers, type AnalyticsEventOutboxRow } from './schema'; + +// ----- constants ----------------------------------------------------------- + +/** A row fails terminally after this many send attempts. */ +export const OUTBOX_MAX_ATTEMPTS = 8; + +/** A `sending` claim older than this is stale and gets reclaimed. */ +export const OUTBOX_STALE_SENDING_WINDOW_MS = 5 * 60 * 1000; + +/** Retry backoff constants (same shape as the affiliate event drainer). */ +export const OUTBOX_INITIAL_RETRY_BACKOFF_MS = 60 * 1000; +export const OUTBOX_MAX_RETRY_BACKOFF_MS = 60 * 60 * 1000; + +/** DEC-01 retention windows. */ +export const OUTBOX_DELIVERED_RETENTION_DAYS = 7; +export const OUTBOX_FAILED_RETENTION_DAYS = 30; + +/** Outcome code written by the cron backstop for expired non-terminal rows. */ +export const EXPIRED_UNSETTLED_OUTCOME_CODE = 'expired_unsettled'; + +// ----- result types ----------------------------------------------------------- + +export type OutboxRetryResult = + | { outcome: 'retried'; row: AnalyticsEventOutboxRow } + | { outcome: 'failed'; row: AnalyticsEventOutboxRow }; + +export type PurgeExpiredResult = { + outboxDeliveredPurged: number; + outboxFailedPurged: number; + expiredUnsettledLedgerSettled: number; +}; + +// ----- claim ------------------------------------------------------------------ + +/** + * Claims due `pending` rows in a bounded batch: transitions them to `sending` + * with `claimed_at`, ordered oldest-first, `FOR UPDATE SKIP LOCKED` so + * concurrent drainers never double-claim. A row is due when + * `next_attempt_at` is null or in the past. + */ +export async function claimDueOutboxEvents( + database: LedgerDatabase, + limit: number +): Promise { + return database + .update(analytics_event_outbox) + .set({ + status: 'sending', + claimed_at: sql`now()`, + }) + .where(sql`${analytics_event_outbox.id} IN ( + SELECT ${analytics_event_outbox.id} + FROM ${analytics_event_outbox} + WHERE ${analytics_event_outbox.status} = 'pending' + AND coalesce(${analytics_event_outbox.next_attempt_at}, '-infinity'::timestamptz) <= now() + ORDER BY ${analytics_event_outbox.created_at} ASC, ${analytics_event_outbox.id} ASC + LIMIT ${limit} + FOR UPDATE SKIP LOCKED + )`) + .returning(); +} + +// ----- terminal marks ----------------------------------------------------------- + +/** + * Marks a claimed event delivered. The update is fenced on the claim token + * `claimedAt`: it only matches while the row is still the `sending` claim + * identified by that timestamp. A late mark from a sender whose claim was + * stale-reclaimed and re-claimed, or a replay after delivery, affects zero + * rows and returns null. Clears the claim and the retry clock; `delivered_at` + * drives the 7-day purge. + */ +export async function markOutboxDelivered( + database: LedgerDatabase, + input: { eventId: string; claimedAt: string } +): Promise { + const [updated] = await database + .update(analytics_event_outbox) + .set({ + status: 'delivered', + delivered_at: sql`now()`, + next_attempt_at: null, + claimed_at: null, + }) + .where( + and( + eq(analytics_event_outbox.id, input.eventId), + eq(analytics_event_outbox.status, 'sending'), + eq(analytics_event_outbox.claimed_at, input.claimedAt) + ) + ) + .returning(); + return updated ?? null; +} + +/** + * Marks a claimed event for backoff retry or terminal failure in one atomic, + * claim-fenced update. The `claimedAt` token in the WHERE clause covers the + * whole transition: it matches only while the row is still the `sending` + * claim identified by that timestamp. A stale sender's late retry — after its + * claim was reclaimed and re-claimed, or after delivery — affects zero rows + * and returns null instead of requeueing or failing the newer claim. + * + * The same statement increments `attempts` and computes the next state: when + * the new attempt count reaches `OUTBOX_MAX_ATTEMPTS` the event transitions + * to `failed` with `next_attempt_at` cleared; otherwise it returns to + * `pending` with the exponential backoff deadline. Because both outcomes + * commit in one UPDATE, a crash mid-transition cannot orphan the row in a + * half-updated `sending` claim. + */ +export async function markOutboxRetry( + database: LedgerDatabase, + input: { eventId: string; claimedAt: string; error?: string | null } +): Promise { + const [row] = await database + .update(analytics_event_outbox) + .set({ + attempts: sql`${analytics_event_outbox.attempts} + 1`, + status: sql`case when ${analytics_event_outbox.attempts} + 1 >= ${OUTBOX_MAX_ATTEMPTS} then 'failed' else 'pending' end`, + next_attempt_at: sql`case + when ${analytics_event_outbox.attempts} + 1 >= ${OUTBOX_MAX_ATTEMPTS} then null + else now() + (least(${OUTBOX_INITIAL_RETRY_BACKOFF_MS} * pow(2.0, ${analytics_event_outbox.attempts}::float8), ${OUTBOX_MAX_RETRY_BACKOFF_MS}) * interval '1 millisecond') + end`, + claimed_at: null, + last_error: input.error ?? null, + }) + .where( + and( + eq(analytics_event_outbox.id, input.eventId), + eq(analytics_event_outbox.status, 'sending'), + eq(analytics_event_outbox.claimed_at, input.claimedAt) + ) + ) + .returning(); + + if (!row) { + // The claim is no longer active (reclaimed, delivered, failed, or purged). + return null; + } + + return row.attempts >= OUTBOX_MAX_ATTEMPTS + ? { outcome: 'failed', row } + : { outcome: 'retried', row }; +} + +/** + * Force-marks a claimed event failed (used for definitive, non-retryable send + * errors). The update is fenced on the claim token `claimedAt`: it only + * matches while the row is still the `sending` claim identified by that + * timestamp; a late mark from a stale sender affects zero rows and returns + * null. Increments `attempts` so the failure is visible in the count. + */ +export async function markOutboxFailed( + database: LedgerDatabase, + input: { eventId: string; claimedAt: string; error?: string | null } +): Promise { + const [updated] = await database + .update(analytics_event_outbox) + .set({ + status: 'failed', + attempts: sql`${analytics_event_outbox.attempts} + 1`, + next_attempt_at: null, + claimed_at: null, + last_error: input.error ?? null, + }) + .where( + and( + eq(analytics_event_outbox.id, input.eventId), + eq(analytics_event_outbox.status, 'sending'), + eq(analytics_event_outbox.claimed_at, input.claimedAt) + ) + ) + .returning(); + return updated ?? null; +} + +// ----- reclaim ----------------------------------------------------------------- + +/** + * Reclaims `sending` rows whose claim is older than + * `OUTBOX_STALE_SENDING_WINDOW_MS`: they return to `pending` and become due + * again. Covers the crash window where a drainer died after claiming. + */ +export async function reclaimStaleSendingEvents( + database: LedgerDatabase +): Promise { + const staleBefore = new Date(Date.now() - OUTBOX_STALE_SENDING_WINDOW_MS).toISOString(); + return database + .update(analytics_event_outbox) + .set({ status: 'pending', claimed_at: null }) + .where( + and( + eq(analytics_event_outbox.status, 'sending'), + sql`${analytics_event_outbox.claimed_at} <= ${staleBefore}::timestamptz` + ) + ) + .returning(); +} + +// ----- purge and backstop --------------------------------------------------------- + +/** + * Enforces DEC-01 retention and runs the `expired_unsettled` ledger backstop: + * - deletes `delivered` outbox rows older than `deliveredRetentionDays` (7); + * - deletes `failed` outbox rows older than `failedRetentionDays` (30); + * - settles non-terminal ledger rows past `expires_at` as `failed` with + * `outcome_code: 'expired_unsettled'` and no outbox event. + */ +export async function purgeExpired( + database: LedgerDatabase, + params?: { deliveredRetentionDays?: number; failedRetentionDays?: number } +): Promise { + const deliveredRetentionDays = params?.deliveredRetentionDays ?? OUTBOX_DELIVERED_RETENTION_DAYS; + const failedRetentionDays = params?.failedRetentionDays ?? OUTBOX_FAILED_RETENTION_DAYS; + + const delivered = await database + .delete(analytics_event_outbox) + .where( + and( + eq(analytics_event_outbox.status, 'delivered'), + sql`${analytics_event_outbox.delivered_at} < now() - make_interval(days => ${deliveredRetentionDays})` + ) + ) + .returning({ id: analytics_event_outbox.id }); + + const failed = await database + .delete(analytics_event_outbox) + .where( + and( + eq(analytics_event_outbox.status, 'failed'), + sql`${analytics_event_outbox.created_at} < now() - make_interval(days => ${failedRetentionDays})` + ) + ) + .returning({ id: analytics_event_outbox.id }); + + const backstop = await database + .update(operation_ledgers) + .set({ + status: 'failed', + outcome_code: EXPIRED_UNSETTLED_OUTCOME_CODE, + settled_at: sql`now()`, + }) + .where( + and( + inArray(operation_ledgers.status, OPERATION_NON_TERMINAL_STATUSES), + sql`${operation_ledgers.expires_at} < now()` + ) + ) + .returning({ id: operation_ledgers.id }); + + return { + outboxDeliveredPurged: delivered.length, + outboxFailedPurged: failed.length, + expiredUnsettledLedgerSettled: backstop.length, + }; +} diff --git a/packages/db/src/migrations/0206_operation_ledgers_and_analytics_outbox.sql b/packages/db/src/migrations/0206_operation_ledgers_and_analytics_outbox.sql new file mode 100644 index 0000000000..e6f1132f99 --- /dev/null +++ b/packages/db/src/migrations/0206_operation_ledgers_and_analytics_outbox.sql @@ -0,0 +1,39 @@ +CREATE TABLE "analytics_event_outbox" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "event_uuid" uuid NOT NULL, + "event_name" text NOT NULL, + "distinct_id" text NOT NULL, + "properties" jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "next_attempt_at" timestamp with time zone, + "claimed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "delivered_at" timestamp with time zone, + "last_error" text +); +--> statement-breakpoint +CREATE TABLE "operation_ledgers" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "operation_key" text NOT NULL, + "domain" text NOT NULL, + "intent" text NOT NULL, + "kilo_user_id" text NOT NULL, + "organization_id" text, + "resource_key" text, + "provider_ref" text, + "taxonomy" text NOT NULL, + "status" text DEFAULT 'admitted' NOT NULL, + "outcome_code" text, + "canonical_result" jsonb, + "admitted_at" timestamp with time zone DEFAULT now() NOT NULL, + "settled_at" timestamp with time zone, + "lease_expires_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "UQ_analytics_event_outbox_event_uuid" ON "analytics_event_outbox" USING btree ("event_uuid");--> statement-breakpoint +CREATE INDEX "IDX_analytics_event_outbox_status_next_attempt_at" ON "analytics_event_outbox" USING btree ("status","next_attempt_at");--> statement-breakpoint +CREATE UNIQUE INDEX "UQ_operation_ledgers_kilo_user_id_domain_operation_key" ON "operation_ledgers" USING btree ("kilo_user_id","domain","operation_key");--> statement-breakpoint +CREATE INDEX "IDX_operation_ledgers_status_expires_at" ON "operation_ledgers" USING btree ("status","expires_at");--> statement-breakpoint +CREATE INDEX "IDX_operation_ledgers_provider_ref" ON "operation_ledgers" USING btree ("provider_ref"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0206_snapshot.json b/packages/db/src/migrations/meta/0206_snapshot.json new file mode 100644 index 0000000000..7588a92871 --- /dev/null +++ b/packages/db/src/migrations/meta/0206_snapshot.json @@ -0,0 +1,36631 @@ +{ + "id": "a6b75a66-2b36-4805-b00d-01ab4ac62515", + "prevId": "608de91d-8833-4991-b4e2-e32402f69d60", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_configs": { + "name": "agent_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "runtime_state": { + "name": "runtime_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_configs_org_id": { + "name": "IDX_agent_configs_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_owned_by_user_id": { + "name": "IDX_agent_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_agent_type": { + "name": "IDX_agent_configs_agent_type", + "columns": [ + { + "expression": "agent_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_platform": { + "name": "IDX_agent_configs_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_configs_owned_by_organization_id_organizations_id_fk": { + "name": "agent_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_configs_org_agent_platform": { + "name": "UQ_agent_configs_org_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "agent_type", + "platform" + ] + }, + "UQ_agent_configs_user_agent_platform": { + "name": "UQ_agent_configs_user_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_user_id", + "agent_type", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": { + "agent_configs_owner_check": { + "name": "agent_configs_owner_check", + "value": "(\n (\"agent_configs\".\"owned_by_user_id\" IS NOT NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_configs\".\"owned_by_user_id\" IS NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "agent_configs_agent_type_check": { + "name": "agent_configs_agent_type_check", + "value": "\"agent_configs\".\"agent_type\" IN ('code_review', 'auto_triage', 'auto_fix', 'security_scan')" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_agents": { + "name": "agent_environment_profile_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_agents_profile_id": { + "name": "IDX_agent_env_profile_agents_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_agents", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_agents_profile_slug": { + "name": "UQ_agent_env_profile_agents_profile_slug", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_commands": { + "name": "agent_environment_profile_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_commands_profile_id": { + "name": "IDX_agent_env_profile_commands_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_commands_profile_sequence": { + "name": "UQ_agent_env_profile_commands_profile_sequence", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "sequence" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_kilo_commands": { + "name": "agent_environment_profile_kilo_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtask": { + "name": "subtask", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_kilo_cmds_profile_id": { + "name": "IDX_agent_env_profile_kilo_cmds_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_kilo_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_kilo_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_kilo_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_kilo_cmds_profile_name": { + "name": "UQ_agent_env_profile_kilo_cmds_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_mcp_servers": { + "name": "agent_environment_profile_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_mcp_servers_profile_id": { + "name": "IDX_agent_env_profile_mcp_servers_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_mcp_servers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_mcp_servers_profile_name": { + "name": "UQ_agent_env_profile_mcp_servers_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_repo_bindings": { + "name": "agent_environment_profile_repo_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profile_repo_bindings_user": { + "name": "UQ_agent_env_profile_repo_bindings_user", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profile_repo_bindings_org": { + "name": "UQ_agent_env_profile_repo_bindings_org", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profile_repo_bindings_owner_check": { + "name": "agent_env_profile_repo_bindings_owner_check", + "value": "(\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_skills": { + "name": "agent_environment_profile_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_markdown": { + "name": "raw_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_skills_profile_id": { + "name": "IDX_agent_env_profile_skills_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_skills", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_skills_profile_name": { + "name": "UQ_agent_env_profile_skills_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_vars": { + "name": "agent_environment_profile_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_vars_profile_id": { + "name": "IDX_agent_env_profile_vars_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_vars", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_vars_profile_key": { + "name": "UQ_agent_env_profile_vars_profile_key", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profiles": { + "name": "agent_environment_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_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 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profiles_org_name": { + "name": "UQ_agent_env_profiles_org_name", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_name": { + "name": "UQ_agent_env_profiles_user_name", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_org_default": { + "name": "UQ_agent_env_profiles_org_default", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_default": { + "name": "UQ_agent_env_profiles_user_default", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_org_id": { + "name": "IDX_agent_env_profiles_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_user_id": { + "name": "IDX_agent_env_profiles_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_created_by_user_id": { + "name": "IDX_agent_env_profiles_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profiles_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profiles_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profiles_owner_check": { + "name": "agent_env_profiles_owner_check", + "value": "(\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.analytics_event_outbox": { + "name": "analytics_event_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "event_uuid": { + "name": "event_uuid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "distinct_id": { + "name": "distinct_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_analytics_event_outbox_event_uuid": { + "name": "UQ_analytics_event_outbox_event_uuid", + "columns": [ + { + "expression": "event_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_analytics_event_outbox_status_next_attempt_at": { + "name": "IDX_analytics_event_outbox_status_next_attempt_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_kind": { + "name": "api_kind", + "schema": "", + "columns": { + "api_kind_id": { + "name": "api_kind_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_api_kind": { + "name": "UQ_api_kind", + "columns": [ + { + "expression": "api_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_request_compress_log": { + "name": "api_request_compress_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_api_request_compress_log_created_at": { + "name": "idx_api_request_compress_log_created_at", + "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.api_request_log": { + "name": "api_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_request_id": { + "name": "vercel_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_request_log_created_at": { + "name": "idx_api_request_log_created_at", + "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.app_builder_feedback": { + "name": "app_builder_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_status": { + "name": "preview_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_feedback_created_at": { + "name": "IDX_app_builder_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_kilo_user_id": { + "name": "IDX_app_builder_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_project_id": { + "name": "IDX_app_builder_feedback_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "app_builder_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "app_builder_feedback_project_id_app_builder_projects_id_fk": { + "name": "app_builder_feedback_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_project_sessions": { + "name": "app_builder_project_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v2'" + } + }, + "indexes": { + "IDX_app_builder_project_sessions_project_id": { + "name": "IDX_app_builder_project_sessions_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_project_sessions_project_id_app_builder_projects_id_fk": { + "name": "app_builder_project_sessions_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_project_sessions", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_app_builder_project_sessions_cloud_agent_session_id": { + "name": "UQ_app_builder_project_sessions_cloud_agent_session_id", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_projects": { + "name": "app_builder_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "git_repo_full_name": { + "name": "git_repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_platform_integration_id": { + "name": "git_platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_projects_created_by_user_id": { + "name": "IDX_app_builder_projects_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_user_id": { + "name": "IDX_app_builder_projects_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_organization_id": { + "name": "IDX_app_builder_projects_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_created_at": { + "name": "IDX_app_builder_projects_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_last_message_at": { + "name": "IDX_app_builder_projects_last_message_at", + "columns": [ + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_git_repo_integration": { + "name": "IDX_app_builder_projects_git_repo_integration", + "columns": [ + { + "expression": "git_repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"app_builder_projects\".\"git_repo_full_name\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_projects_owned_by_user_id_kilocode_users_id_fk": { + "name": "app_builder_projects_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_owned_by_organization_id_organizations_id_fk": { + "name": "app_builder_projects_owned_by_organization_id_organizations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_deployment_id_deployments_id_fk": { + "name": "app_builder_projects_deployment_id_deployments_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk": { + "name": "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "platform_integrations", + "columnsFrom": [ + "git_platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_builder_projects_owner_check": { + "name": "app_builder_projects_owner_check", + "value": "(\n (\"app_builder_projects\".\"owned_by_user_id\" IS NOT NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NULL) OR\n (\"app_builder_projects\".\"owned_by_user_id\" IS NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.app_min_versions": { + "name": "app_min_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ios_min_version": { + "name": "ios_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "android_min_version": { + "name": "android_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_reported_messages": { + "name": "app_reported_messages", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_reported_messages_cli_session_id_cli_sessions_session_id_fk": { + "name": "app_reported_messages_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "app_reported_messages", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_fix_tickets": { + "name": "auto_fix_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "triage_ticket_id": { + "name": "triage_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'label'" + }, + "review_comment_id": { + "name": "review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "review_comment_body": { + "name": "review_comment_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diff_hunk": { + "name": "diff_hunk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_ref": { + "name": "pr_head_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_branch": { + "name": "pr_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_fix_tickets_repo_issue": { + "name": "UQ_auto_fix_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"trigger_source\" = 'label'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_fix_tickets_repo_review_comment": { + "name": "UQ_auto_fix_tickets_repo_review_comment", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"review_comment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_org": { + "name": "IDX_auto_fix_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_user": { + "name": "IDX_auto_fix_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_status": { + "name": "IDX_auto_fix_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_created_at": { + "name": "IDX_auto_fix_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_triage_ticket_id": { + "name": "IDX_auto_fix_tickets_triage_ticket_id", + "columns": [ + { + "expression": "triage_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_session_id": { + "name": "IDX_auto_fix_tickets_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_fix_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_fix_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "triage_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk": { + "name": "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_fix_tickets_owner_check": { + "name": "auto_fix_tickets_owner_check", + "value": "(\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_fix_tickets_status_check": { + "name": "auto_fix_tickets_status_check", + "value": "\"auto_fix_tickets\".\"status\" IN ('pending', 'running', 'completed', 'failed', 'cancelled')" + }, + "auto_fix_tickets_classification_check": { + "name": "auto_fix_tickets_classification_check", + "value": "\"auto_fix_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'unclear')" + }, + "auto_fix_tickets_confidence_check": { + "name": "auto_fix_tickets_confidence_check", + "value": "\"auto_fix_tickets\".\"confidence\" >= 0 AND \"auto_fix_tickets\".\"confidence\" <= 1" + }, + "auto_fix_tickets_trigger_source_check": { + "name": "auto_fix_tickets_trigger_source_check", + "value": "\"auto_fix_tickets\".\"trigger_source\" IN ('label', 'review_comment')" + } + }, + "isRLSEnabled": false + }, + "public.auto_model": { + "name": "auto_model", + "schema": "", + "columns": { + "auto_model_id": { + "name": "auto_model_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_auto_model": { + "name": "UQ_auto_model", + "columns": [ + { + "expression": "auto_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_top_up_configs": { + "name": "auto_top_up_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "last_auto_top_up_at": { + "name": "last_auto_top_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_top_up_configs_owned_by_user_id": { + "name": "UQ_auto_top_up_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_top_up_configs_owned_by_organization_id": { + "name": "UQ_auto_top_up_configs_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auto_top_up_configs_owned_by_organization_id_organizations_id_fk": { + "name": "auto_top_up_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_top_up_configs_exactly_one_owner": { + "name": "auto_top_up_configs_exactly_one_owner", + "value": "(\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NULL) OR (\"auto_top_up_configs\".\"owned_by_user_id\" IS NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.auto_triage_tickets": { + "name": "auto_triage_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_duplicate": { + "name": "is_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duplicate_of_ticket_id": { + "name": "duplicate_of_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "similarity_score": { + "name": "similarity_score", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "qdrant_point_id": { + "name": "qdrant_point_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "should_auto_fix": { + "name": "should_auto_fix", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "action_taken": { + "name": "action_taken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_metadata": { + "name": "action_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_triage_tickets_repo_issue": { + "name": "UQ_auto_triage_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_org": { + "name": "IDX_auto_triage_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_user": { + "name": "IDX_auto_triage_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_status": { + "name": "IDX_auto_triage_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_created_at": { + "name": "IDX_auto_triage_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_qdrant_point_id": { + "name": "IDX_auto_triage_tickets_qdrant_point_id", + "columns": [ + { + "expression": "qdrant_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owner_status_created": { + "name": "IDX_auto_triage_tickets_owner_status_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_user_status_created": { + "name": "IDX_auto_triage_tickets_user_status_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_repo_classification": { + "name": "IDX_auto_triage_tickets_repo_classification", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_triage_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_triage_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "duplicate_of_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_triage_tickets_owner_check": { + "name": "auto_triage_tickets_owner_check", + "value": "(\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_triage_tickets_issue_type_check": { + "name": "auto_triage_tickets_issue_type_check", + "value": "\"auto_triage_tickets\".\"issue_type\" IN ('issue', 'pull_request')" + }, + "auto_triage_tickets_classification_check": { + "name": "auto_triage_tickets_classification_check", + "value": "\"auto_triage_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'duplicate', 'unclear')" + }, + "auto_triage_tickets_confidence_check": { + "name": "auto_triage_tickets_confidence_check", + "value": "\"auto_triage_tickets\".\"confidence\" >= 0 AND \"auto_triage_tickets\".\"confidence\" <= 1" + }, + "auto_triage_tickets_similarity_score_check": { + "name": "auto_triage_tickets_similarity_score_check", + "value": "\"auto_triage_tickets\".\"similarity_score\" >= 0 AND \"auto_triage_tickets\".\"similarity_score\" <= 1" + }, + "auto_triage_tickets_status_check": { + "name": "auto_triage_tickets_status_check", + "value": "\"auto_triage_tickets\".\"status\" IN ('pending', 'analyzing', 'actioned', 'failed', 'skipped')" + }, + "auto_triage_tickets_action_taken_check": { + "name": "auto_triage_tickets_action_taken_check", + "value": "\"auto_triage_tickets\".\"action_taken\" IN ('pr_created', 'comment_posted', 'closed_duplicate', 'needs_clarification')" + } + }, + "isRLSEnabled": false + }, + "public.bot_request_cloud_agent_sessions": { + "name": "bot_request_cloud_agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "bot_request_id": { + "name": "bot_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spawn_group_id": { + "name": "spawn_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlab_project": { + "name": "gitlab_project", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_step": { + "name": "callback_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message": { + "name": "final_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message_fetched_at": { + "name": "final_message_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "final_message_error": { + "name": "final_message_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "continuation_started_at": { + "name": "continuation_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_bot_request_cas_cloud_agent_session_id": { + "name": "UQ_bot_request_cas_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id": { + "name": "IDX_bot_request_cas_bot_request_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id_status": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id_status", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk": { + "name": "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk", + "tableFrom": "bot_request_cloud_agent_sessions", + "tableTo": "bot_requests", + "columnsFrom": [ + "bot_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_requests": { + "name": "bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_thread_id": { + "name": "platform_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_bot_requests_created_at": { + "name": "IDX_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_created_by": { + "name": "IDX_bot_requests_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_organization_id": { + "name": "IDX_bot_requests_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_platform_integration_id": { + "name": "IDX_bot_requests_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_status": { + "name": "IDX_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_requests_created_by_kilocode_users_id_fk": { + "name": "bot_requests_created_by_kilocode_users_id_fk", + "tableFrom": "bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_organization_id_organizations_id_fk": { + "name": "bot_requests_organization_id_organizations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.byok_api_keys": { + "name": "byok_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "management_source": { + "name": "management_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_byok_api_keys_organization_id": { + "name": "IDX_byok_api_keys_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_kilo_user_id": { + "name": "IDX_byok_api_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_provider_id": { + "name": "IDX_byok_api_keys_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "byok_api_keys_organization_id_organizations_id_fk": { + "name": "byok_api_keys_organization_id_organizations_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "byok_api_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "byok_api_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_byok_api_keys_org_provider": { + "name": "UQ_byok_api_keys_org_provider", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider_id" + ] + }, + "UQ_byok_api_keys_user_provider": { + "name": "UQ_byok_api_keys_user_provider", + "nullsNotDistinct": false, + "columns": [ + "kilo_user_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "byok_api_keys_management_source_check": { + "name": "byok_api_keys_management_source_check", + "value": "\"byok_api_keys\".\"management_source\" IN ('user', 'coding_plan')" + }, + "byok_api_keys_owner_check": { + "name": "byok_api_keys_owner_check", + "value": "(\n (\"byok_api_keys\".\"kilo_user_id\" IS NOT NULL AND \"byok_api_keys\".\"organization_id\" IS NULL) OR\n (\"byok_api_keys\".\"kilo_user_id\" IS NULL AND \"byok_api_keys\".\"organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cli_sessions": { + "name": "cli_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_mode": { + "name": "last_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_kilo_user_id": { + "name": "IDX_cli_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_created_at": { + "name": "IDX_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_updated_at": { + "name": "IDX_cli_sessions_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_organization_id": { + "name": "IDX_cli_sessions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_user_updated": { + "name": "IDX_cli_sessions_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_forked_from_cli_sessions_session_id_fk": { + "name": "cli_sessions_forked_from_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_parent_session_id_cli_sessions_session_id_fk": { + "name": "cli_sessions_parent_session_id_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_organization_id_organizations_id_fk": { + "name": "cli_sessions_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cli_sessions_cloud_agent_session_id_unique": { + "name": "cli_sessions_cloud_agent_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_sessions_v2": { + "name": "cli_sessions_v2", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_scope_id": { + "name": "cloud_agent_session_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_v2_parent_session_id_kilo_user_id": { + "name": "IDX_cli_sessions_v2_parent_session_id_kilo_user_id", + "columns": [ + { + "expression": "parent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_public_id": { + "name": "UQ_cli_sessions_v2_public_id", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"public_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_cloud_agent_session_id": { + "name": "UQ_cli_sessions_v2_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"cloud_agent_session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_organization_id": { + "name": "IDX_cli_sessions_v2_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_kilo_user_id": { + "name": "IDX_cli_sessions_v2_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_created_at": { + "name": "IDX_cli_sessions_v2_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_updated": { + "name": "IDX_cli_sessions_v2_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_sessions_v2_git_url_branch_idx": { + "name": "cli_sessions_v2_git_url_branch_idx", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_v2_organization_id_organizations_id_fk": { + "name": "cli_sessions_v2_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_v2_parent_session_id_kilo_user_id_fk": { + "name": "cli_sessions_v2_parent_session_id_kilo_user_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "cli_sessions_v2", + "columnsFrom": [ + "parent_session_id", + "kilo_user_id" + ], + "columnsTo": [ + "session_id", + "kilo_user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cli_sessions_v2_session_id_kilo_user_id_pk": { + "name": "cli_sessions_v2_session_id_kilo_user_id_pk", + "columns": [ + "session_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_code_review_attempts": { + "name": "cloud_agent_code_review_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_review_id": { + "name": "code_review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retry_of_attempt_id": { + "name": "retry_of_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analytics_enabled_at_dispatch": { + "name": "analytics_enabled_at_dispatch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_code_review_attempts_review_attempt_number": { + "name": "UQ_cloud_agent_code_review_attempts_review_attempt_number", + "columns": [ + { + "expression": "code_review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_code_review_id": { + "name": "idx_cloud_agent_code_review_attempts_code_review_id", + "columns": [ + { + "expression": "code_review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_session_id": { + "name": "idx_cloud_agent_code_review_attempts_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_cli_session_id": { + "name": "idx_cloud_agent_code_review_attempts_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_status": { + "name": "idx_cloud_agent_code_review_attempts_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_retry_reason": { + "name": "idx_cloud_agent_code_review_attempts_retry_reason", + "columns": [ + { + "expression": "retry_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_review_attempts_code_review_id_cloud_agent_code_reviews_id_fk": { + "name": "cloud_agent_code_review_attempts_code_review_id_cloud_agent_code_reviews_id_fk", + "tableFrom": "cloud_agent_code_review_attempts", + "tableTo": "cloud_agent_code_reviews", + "columnsFrom": [ + "code_review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_review_attempts_retry_of_attempt_id_cloud_agent_code_review_attempts_id_fk": { + "name": "cloud_agent_code_review_attempts_retry_of_attempt_id_cloud_agent_code_review_attempts_id_fk", + "tableFrom": "cloud_agent_code_review_attempts", + "tableTo": "cloud_agent_code_review_attempts", + "columnsFrom": [ + "retry_of_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_review_attempts_attempt_number_check": { + "name": "cloud_agent_code_review_attempts_attempt_number_check", + "value": "\"cloud_agent_code_review_attempts\".\"attempt_number\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_code_reviews": { + "name": "cloud_agent_code_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "manual_config": { + "name": "manual_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "review_type": { + "name": "review_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "council_result": { + "name": "council_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "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 + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author": { + "name": "pr_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author_github_id": { + "name": "pr_author_github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_ref": { + "name": "head_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "dispatch_reservation_id": { + "name": "dispatch_reservation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'v1'" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "repository_review_instructions_used": { + "name": "repository_review_instructions_used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "repository_review_instructions_ref": { + "name": "repository_review_instructions_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_review_instructions_truncated": { + "name": "repository_review_instructions_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previous_summary_body": { + "name": "previous_summary_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_summary_head_sha": { + "name": "previous_summary_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens_in": { + "name": "total_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens_out": { + "name": "total_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cost_musd": { + "name": "total_cost_musd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_code_reviews_webhook_integration_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_webhook_integration_repo_pr_sha", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_code_reviews\".\"manual_config\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_code_reviews_active_provider_publisher": { + "name": "UQ_cloud_agent_code_reviews_active_provider_publisher", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_code_reviews\".\"platform_integration_id\" IS NOT NULL\n AND \"cloud_agent_code_reviews\".\"status\" IN ('pending', 'queued', 'running')\n AND (\"cloud_agent_code_reviews\".\"manual_config\" IS NULL OR \"cloud_agent_code_reviews\".\"manual_config\"->>'outputMode' = 'provider')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_org_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_user_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_session_id": { + "name": "idx_cloud_agent_code_reviews_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_cli_session_id": { + "name": "idx_cloud_agent_code_reviews_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_status": { + "name": "idx_cloud_agent_code_reviews_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_repo": { + "name": "idx_cloud_agent_code_reviews_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_number": { + "name": "idx_cloud_agent_code_reviews_pr_number", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_created_at": { + "name": "idx_cloud_agent_code_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_author_github_id": { + "name": "idx_cloud_agent_code_reviews_pr_author_github_id", + "columns": [ + { + "expression": "pr_author_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk": { + "name": "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_reviews_owner_check": { + "name": "cloud_agent_code_reviews_owner_check", + "value": "(\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NOT NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NULL) OR\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_feedback": { + "name": "cloud_agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_type": { + "name": "session_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cloud_agent_feedback_created_at": { + "name": "IDX_cloud_agent_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_kilo_user_id": { + "name": "IDX_cloud_agent_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_cloud_agent_session_id": { + "name": "IDX_cloud_agent_feedback_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "cloud_agent_feedback_organization_id_organizations_id_fk": { + "name": "cloud_agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_session_runs": { + "name": "cloud_agent_session_runs", + "schema": "", + "columns": { + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapper_run_id": { + "name": "wrapper_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_accepted_at": { + "name": "dispatch_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_activity_observed_at": { + "name": "agent_activity_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_responsibility": { + "name": "failure_responsibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message_redacted": { + "name": "error_message_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_expires_at": { + "name": "error_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_cloud_agent_session_runs_wrapper_run_id": { + "name": "IDX_cloud_agent_session_runs_wrapper_run_id", + "columns": [ + { + "expression": "wrapper_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"wrapper_run_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_session_queued": { + "name": "IDX_cloud_agent_session_runs_session_queued", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_queued_at": { + "name": "IDX_cloud_agent_session_runs_queued_at", + "columns": [ + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_terminal_at": { + "name": "IDX_cloud_agent_session_runs_terminal_at", + "columns": [ + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_status_terminal": { + "name": "IDX_cloud_agent_session_runs_status_terminal", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_failure_terminal": { + "name": "IDX_cloud_agent_session_runs_failure_terminal", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_responsibility_reason_terminal": { + "name": "IDX_cloud_agent_session_runs_responsibility_reason_terminal", + "columns": [ + { + "expression": "failure_responsibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"status\" = 'failed'", + "concurrently": true, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_error_expires_at": { + "name": "IDX_cloud_agent_session_runs_error_expires_at", + "columns": [ + { + "expression": "error_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"error_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_session_runs_cloud_agent_session_id_cloud_agent_sessions_cloud_agent_session_id_fk": { + "name": "cloud_agent_session_runs_cloud_agent_session_id_cloud_agent_sessions_cloud_agent_session_id_fk", + "tableFrom": "cloud_agent_session_runs", + "tableTo": "cloud_agent_sessions", + "columnsFrom": [ + "cloud_agent_session_id" + ], + "columnsTo": [ + "cloud_agent_session_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cloud_agent_session_runs_cloud_agent_session_id_message_id_pk": { + "name": "cloud_agent_session_runs_cloud_agent_session_id_message_id_pk", + "columns": [ + "cloud_agent_session_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_session_runs_status_check": { + "name": "cloud_agent_session_runs_status_check", + "value": "\"cloud_agent_session_runs\".\"status\" IN ('queued', 'accepted', 'completed', 'failed', 'interrupted')" + }, + "cloud_agent_session_runs_error_message_bounded_check": { + "name": "cloud_agent_session_runs_error_message_bounded_check", + "value": "\"cloud_agent_session_runs\".\"error_message_redacted\" IS NULL OR char_length(\"cloud_agent_session_runs\".\"error_message_redacted\") <= 4096" + }, + "cloud_agent_session_runs_error_expiry_check": { + "name": "cloud_agent_session_runs_error_expiry_check", + "value": "(\"cloud_agent_session_runs\".\"error_message_redacted\" IS NULL AND \"cloud_agent_session_runs\".\"error_expires_at\" IS NULL) OR\n (\"cloud_agent_session_runs\".\"error_message_redacted\" IS NOT NULL AND \"cloud_agent_session_runs\".\"error_expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_sessions": { + "name": "cloud_agent_sessions", + "schema": "", + "columns": { + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initial_message_id": { + "name": "initial_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "failure_at": { + "name": "failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_responsibility": { + "name": "failure_responsibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message_redacted": { + "name": "error_message_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_expires_at": { + "name": "error_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_cloud_agent_sessions_kilo_session_id": { + "name": "UQ_cloud_agent_sessions_kilo_session_id", + "columns": [ + { + "expression": "kilo_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_sessions_initial_message_id": { + "name": "UQ_cloud_agent_sessions_initial_message_id", + "columns": [ + { + "expression": "initial_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_sandbox_id": { + "name": "IDX_cloud_agent_sessions_sandbox_id", + "columns": [ + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"sandbox_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_created_at": { + "name": "IDX_cloud_agent_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_created": { + "name": "IDX_cloud_agent_sessions_failure_created", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_at": { + "name": "IDX_cloud_agent_sessions_failure_at", + "columns": [ + { + "expression": "failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"failure_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_classification_at": { + "name": "IDX_cloud_agent_sessions_failure_classification_at", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"failure_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_error_expires_at": { + "name": "IDX_cloud_agent_sessions_error_expires_at", + "columns": [ + { + "expression": "error_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"error_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_sessions_failure_classification_check": { + "name": "cloud_agent_sessions_failure_classification_check", + "value": "(\"cloud_agent_sessions\".\"failure_at\" IS NULL AND \"cloud_agent_sessions\".\"failure_stage\" IS NULL AND \"cloud_agent_sessions\".\"failure_code\" IS NULL) OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'sandbox_identity' AND \"cloud_agent_sessions\".\"failure_code\" = 'sandbox_id_derivation_failed') OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'registration' AND \"cloud_agent_sessions\".\"failure_code\" = 'do_registration_rejected') OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'initial_admission' AND \"cloud_agent_sessions\".\"failure_code\" IN ('initial_admission_rejected', 'initial_queue_full', 'invalid_initial_intent')) OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'transport' AND \"cloud_agent_sessions\".\"failure_code\" = 'do_rpc_outcome_unknown')" + }, + "cloud_agent_sessions_error_message_bounded_check": { + "name": "cloud_agent_sessions_error_message_bounded_check", + "value": "\"cloud_agent_sessions\".\"error_message_redacted\" IS NULL OR char_length(\"cloud_agent_sessions\".\"error_message_redacted\") <= 4096" + }, + "cloud_agent_sessions_error_expiry_check": { + "name": "cloud_agent_sessions_error_expiry_check", + "value": "(\"cloud_agent_sessions\".\"error_message_redacted\" IS NULL AND \"cloud_agent_sessions\".\"error_expires_at\" IS NULL) OR\n (\"cloud_agent_sessions\".\"error_message_redacted\" IS NOT NULL AND \"cloud_agent_sessions\".\"error_expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_webhook_triggers": { + "name": "cloud_agent_webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cloud_agent'" + }, + "kiloclaw_instance_id": { + "name": "kiloclaw_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "activation_mode": { + "name": "activation_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'webhook'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_webhook_triggers_user_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_user_trigger", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_webhook_triggers_org_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_org_trigger", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_user": { + "name": "IDX_cloud_agent_webhook_triggers_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_org": { + "name": "IDX_cloud_agent_webhook_triggers_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_active": { + "name": "IDX_cloud_agent_webhook_triggers_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_profile": { + "name": "IDX_cloud_agent_webhook_triggers_profile", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_organization_id_organizations_id_fk": { + "name": "cloud_agent_webhook_triggers_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk": { + "name": "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "kiloclaw_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk": { + "name": "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_cloud_agent_webhook_triggers_owner": { + "name": "CHK_cloud_agent_webhook_triggers_owner", + "value": "(\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NULL) OR\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_cloud_agent_fields": { + "name": "CHK_cloud_agent_webhook_triggers_cloud_agent_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'cloud_agent' OR\n (\"cloud_agent_webhook_triggers\".\"github_repo\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"profile_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_kiloclaw_fields": { + "name": "CHK_cloud_agent_webhook_triggers_kiloclaw_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'kiloclaw_chat' OR\n \"cloud_agent_webhook_triggers\".\"kiloclaw_instance_id\" IS NOT NULL\n )" + }, + "CHK_cloud_agent_webhook_triggers_scheduled_fields": { + "name": "CHK_cloud_agent_webhook_triggers_scheduled_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"activation_mode\" != 'scheduled' OR\n \"cloud_agent_webhook_triggers\".\"cron_expression\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_billing_sku": { + "name": "cloud_billing_sku", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_cents_per_unit": { + "name": "rate_cents_per_unit", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": true + }, + "accepts_new_usage": { + "name": "accepts_new_usage", + "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 with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cloud_billing_sku_created_by_user_id_kilocode_users_id_fk": { + "name": "cloud_billing_sku_created_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_billing_sku", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_billing_sku_id_format": { + "name": "cloud_billing_sku_id_format", + "value": "\"cloud_billing_sku\".\"id\" ~ '^[a-z0-9][a-z0-9-]{2,79}$'" + }, + "cloud_billing_sku_name_nonempty": { + "name": "cloud_billing_sku_name_nonempty", + "value": "length(btrim(\"cloud_billing_sku\".\"name\")) > 0" + }, + "cloud_billing_sku_rate_positive": { + "name": "cloud_billing_sku_rate_positive", + "value": "\"cloud_billing_sku\".\"rate_cents_per_unit\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.code_indexing_manifest": { + "name": "code_indexing_manifest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines": { + "name": "total_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_ai_lines": { + "name": "total_ai_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_manifest_organization_id": { + "name": "IDX_code_indexing_manifest_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_kilo_user_id": { + "name": "IDX_code_indexing_manifest_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_project_id": { + "name": "IDX_code_indexing_manifest_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_git_branch": { + "name": "IDX_code_indexing_manifest_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_created_at": { + "name": "IDX_code_indexing_manifest_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_manifest", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_indexing_manifest_org_user_project_hash_branch": { + "name": "UQ_code_indexing_manifest_org_user_project_hash_branch", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "kilo_user_id", + "project_id", + "file_path", + "git_branch" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_indexing_search": { + "name": "code_indexing_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_search_organization_id": { + "name": "IDX_code_indexing_search_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_kilo_user_id": { + "name": "IDX_code_indexing_search_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_project_id": { + "name": "IDX_code_indexing_search_project_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_created_at": { + "name": "IDX_code_indexing_search_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_search_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_search_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_search", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_review_analytics_findings": { + "name": "code_review_analytics_findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "analytics_result_id": { + "name": "analytics_result_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "security_class": { + "name": "security_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "code_review_analytics_findings_analytics_result_id_code_review_analytics_results_id_fk": { + "name": "code_review_analytics_findings_analytics_result_id_code_review_analytics_results_id_fk", + "tableFrom": "code_review_analytics_findings", + "tableTo": "code_review_analytics_results", + "columnsFrom": [ + "analytics_result_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_analytics_findings_result_ordinal": { + "name": "UQ_code_review_analytics_findings_result_ordinal", + "nullsNotDistinct": false, + "columns": [ + "analytics_result_id", + "ordinal" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_analytics_findings_severity_check": { + "name": "code_review_analytics_findings_severity_check", + "value": "\"code_review_analytics_findings\".\"severity\" IN ('critical', 'warning', 'suggestion')" + }, + "code_review_analytics_findings_category_check": { + "name": "code_review_analytics_findings_category_check", + "value": "\"code_review_analytics_findings\".\"category\" IN ('security', 'correctness', 'reliability', 'data_integrity', 'performance', 'compatibility', 'maintainability', 'test_quality', 'documentation', 'accessibility', 'other')" + }, + "code_review_analytics_findings_security_class_check": { + "name": "code_review_analytics_findings_security_class_check", + "value": "\"code_review_analytics_findings\".\"security_class\" IN ('auth_access', 'injection', 'data_protection', 'request_resource_boundary', 'deserialization_object_integrity', 'dependency_supply_chain', 'memory_safety', 'availability', 'concurrency', 'security_configuration', 'other')" + }, + "code_review_analytics_findings_ordinal_check": { + "name": "code_review_analytics_findings_ordinal_check", + "value": "\"code_review_analytics_findings\".\"ordinal\" >= 0" + }, + "code_review_analytics_findings_security_class_presence_check": { + "name": "code_review_analytics_findings_security_class_presence_check", + "value": "(\n (\"code_review_analytics_findings\".\"category\" = 'security' AND \"code_review_analytics_findings\".\"security_class\" IS NOT NULL) OR\n (\"code_review_analytics_findings\".\"category\" <> 'security' AND \"code_review_analytics_findings\".\"security_class\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_analytics_results": { + "name": "code_review_analytics_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_review_id": { + "name": "code_review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_attempt_id": { + "name": "source_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "capture_status": { + "name": "capture_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "taxonomy_version": { + "name": "taxonomy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_level": { + "name": "impact_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "complexity_level": { + "name": "complexity_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification_confidence": { + "name": "classification_confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_analytics_results_source_attempt_id": { + "name": "idx_code_review_analytics_results_source_attempt_id", + "columns": [ + { + "expression": "source_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_analytics_results_finalized_at": { + "name": "idx_code_review_analytics_results_finalized_at", + "columns": [ + { + "expression": "finalized_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_analytics_results_code_review_id_cloud_agent_code_reviews_id_fk": { + "name": "code_review_analytics_results_code_review_id_cloud_agent_code_reviews_id_fk", + "tableFrom": "code_review_analytics_results", + "tableTo": "cloud_agent_code_reviews", + "columnsFrom": [ + "code_review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_analytics_results_source_attempt_id_cloud_agent_code_review_attempts_id_fk": { + "name": "code_review_analytics_results_source_attempt_id_cloud_agent_code_review_attempts_id_fk", + "tableFrom": "code_review_analytics_results", + "tableTo": "cloud_agent_code_review_attempts", + "columnsFrom": [ + "source_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_analytics_results_code_review_id": { + "name": "UQ_code_review_analytics_results_code_review_id", + "nullsNotDistinct": false, + "columns": [ + "code_review_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_analytics_results_capture_status_check": { + "name": "code_review_analytics_results_capture_status_check", + "value": "\"code_review_analytics_results\".\"capture_status\" IN ('captured', 'missing', 'invalid', 'omitted')" + }, + "code_review_analytics_results_change_type_check": { + "name": "code_review_analytics_results_change_type_check", + "value": "\"code_review_analytics_results\".\"change_type\" IN ('bug_fix', 'feature', 'refactor', 'maintenance', 'dependency', 'test', 'documentation', 'mixed', 'other')" + }, + "code_review_analytics_results_impact_level_check": { + "name": "code_review_analytics_results_impact_level_check", + "value": "\"code_review_analytics_results\".\"impact_level\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_complexity_level_check": { + "name": "code_review_analytics_results_complexity_level_check", + "value": "\"code_review_analytics_results\".\"complexity_level\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_classification_confidence_check": { + "name": "code_review_analytics_results_classification_confidence_check", + "value": "\"code_review_analytics_results\".\"classification_confidence\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_classification_presence_check": { + "name": "code_review_analytics_results_classification_presence_check", + "value": "(\n (\n \"code_review_analytics_results\".\"capture_status\" = 'captured'\n AND \"code_review_analytics_results\".\"change_type\" IS NOT NULL\n AND \"code_review_analytics_results\".\"impact_level\" IS NOT NULL\n AND \"code_review_analytics_results\".\"complexity_level\" IS NOT NULL\n AND \"code_review_analytics_results\".\"classification_confidence\" IS NOT NULL\n ) OR (\n \"code_review_analytics_results\".\"capture_status\" <> 'captured'\n AND \"code_review_analytics_results\".\"change_type\" IS NULL\n AND \"code_review_analytics_results\".\"impact_level\" IS NULL\n AND \"code_review_analytics_results\".\"complexity_level\" IS NULL\n AND \"code_review_analytics_results\".\"classification_confidence\" IS NULL\n )\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_feedback_events": { + "name": "code_review_feedback_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kilo_comment_id": { + "name": "kilo_comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_excerpt": { + "name": "reply_excerpt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_comment_excerpt": { + "name": "kilo_comment_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_feedback_events_owned_by_org_id": { + "name": "idx_code_review_feedback_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_owned_by_user_id": { + "name": "idx_code_review_feedback_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_platform_repo": { + "name": "idx_code_review_feedback_events_platform_repo", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_created_at": { + "name": "idx_code_review_feedback_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_feedback_events_owned_by_organization_id_organizations_id_fk": { + "name": "code_review_feedback_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "code_review_feedback_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_feedback_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "code_review_feedback_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "code_review_feedback_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_feedback_events_dedupe_hash": { + "name": "UQ_code_review_feedback_events_dedupe_hash", + "nullsNotDistinct": false, + "columns": [ + "dedupe_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_feedback_events_owner_check": { + "name": "code_review_feedback_events_owner_check", + "value": "(\n (\"code_review_feedback_events\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_feedback_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"code_review_feedback_events\".\"owned_by_user_id\" IS NULL AND \"code_review_feedback_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_memory_proposals": { + "name": "code_review_memory_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_markdown": { + "name": "proposed_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "positive_count": { + "name": "positive_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "negative_count": { + "name": "negative_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "neutral_count": { + "name": "neutral_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "change_request_url": { + "name": "change_request_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_memory_proposals_owned_by_org_id": { + "name": "idx_code_review_memory_proposals_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_owned_by_user_id": { + "name": "idx_code_review_memory_proposals_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_platform_repo_status": { + "name": "idx_code_review_memory_proposals_platform_repo_status", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_updated_at": { + "name": "idx_code_review_memory_proposals_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_code_review_memory_proposals_org_active_scope": { + "name": "UQ_code_review_memory_proposals_org_active_scope", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"code_review_memory_proposals\".\"owned_by_organization_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"status\" IN ('open', 'edited', 'opening_change_request')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_code_review_memory_proposals_user_active_scope": { + "name": "UQ_code_review_memory_proposals_user_active_scope", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"code_review_memory_proposals\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"status\" IN ('open', 'edited', 'opening_change_request')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_memory_proposals_owned_by_organization_id_organizations_id_fk": { + "name": "code_review_memory_proposals_owned_by_organization_id_organizations_id_fk", + "tableFrom": "code_review_memory_proposals", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_memory_proposals_owned_by_user_id_kilocode_users_id_fk": { + "name": "code_review_memory_proposals_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "code_review_memory_proposals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "code_review_memory_proposals_owner_check": { + "name": "code_review_memory_proposals_owner_check", + "value": "(\n (\"code_review_memory_proposals\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"owned_by_organization_id\" IS NULL) OR\n (\"code_review_memory_proposals\".\"owned_by_user_id\" IS NULL AND \"code_review_memory_proposals\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_availability_intents": { + "name": "coding_plan_availability_intents", + "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 + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_availability_intents_user_plan": { + "name": "UQ_coding_plan_availability_intents_user_plan", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_availability_intents_plan": { + "name": "IDX_coding_plan_availability_intents_plan", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_availability_intents_user_id_kilocode_users_id_fk": { + "name": "coding_plan_availability_intents_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_availability_intents", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coding_plan_key_inventory": { + "name": "coding_plan_key_inventory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_plan_id": { + "name": "upstream_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_fingerprint": { + "name": "credential_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_requested_at": { + "name": "revocation_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_attempt_count": { + "name": "revocation_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_revocation_error": { + "name": "last_revocation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_key_inv_fingerprint": { + "name": "UQ_coding_plan_key_inv_fingerprint", + "columns": [ + { + "expression": "credential_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_key_inv_plan_status": { + "name": "IDX_coding_plan_key_inv_plan_status", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_key_inv_available": { + "name": "IDX_coding_plan_key_inv_available", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"coding_plan_key_inventory\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_key_inventory_assigned_to_user_id_kilocode_users_id_fk": { + "name": "coding_plan_key_inventory_assigned_to_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_key_inventory", + "tableTo": "kilocode_users", + "columnsFrom": [ + "assigned_to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_key_inventory_status_check": { + "name": "coding_plan_key_inventory_status_check", + "value": "\"coding_plan_key_inventory\".\"status\" IN ('available', 'assigned', 'revocation_pending', 'revoked', 'revocation_failed')" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_subscriptions": { + "name": "coding_plan_subscriptions", + "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 + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_inventory_id": { + "name": "key_inventory_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "installed_byok_key_id": { + "name": "installed_byok_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "billing_period_days": { + "name": "billing_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "past_due_started_at": { + "name": "past_due_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payment_grace_expires_at": { + "name": "payment_grace_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_attempted_for_due": { + "name": "auto_top_up_attempted_for_due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_sub_live_user_plan": { + "name": "UQ_coding_plan_sub_live_user_plan", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_coding_plan_sub_live_user_provider": { + "name": "UQ_coding_plan_sub_live_user_provider", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_status": { + "name": "IDX_coding_plan_sub_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_renewal": { + "name": "IDX_coding_plan_sub_renewal", + "columns": [ + { + "expression": "credit_renewal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_inventory": { + "name": "IDX_coding_plan_sub_inventory", + "columns": [ + { + "expression": "key_inventory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_subscriptions_user_id_kilocode_users_id_fk": { + "name": "coding_plan_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_subscriptions_key_inventory_id_coding_plan_key_inventory_id_fk": { + "name": "coding_plan_subscriptions_key_inventory_id_coding_plan_key_inventory_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "coding_plan_key_inventory", + "columnsFrom": [ + "key_inventory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "coding_plan_subscriptions_installed_byok_key_id_byok_api_keys_id_fk": { + "name": "coding_plan_subscriptions_installed_byok_key_id_byok_api_keys_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "byok_api_keys", + "columnsFrom": [ + "installed_byok_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_subscriptions_status_check": { + "name": "coding_plan_subscriptions_status_check", + "value": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due', 'canceled')" + }, + "coding_plan_subscriptions_live_access_check": { + "name": "coding_plan_subscriptions_live_access_check", + "value": "\"coding_plan_subscriptions\".\"status\" = 'canceled' OR \"coding_plan_subscriptions\".\"key_inventory_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_terms": { + "name": "coding_plan_terms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_terms_request": { + "name": "UQ_coding_plan_terms_request", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_terms_subscription": { + "name": "IDX_coding_plan_terms_subscription", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_terms_subscription_id_coding_plan_subscriptions_id_fk": { + "name": "coding_plan_terms_subscription_id_coding_plan_subscriptions_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "coding_plan_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_terms_user_id_kilocode_users_id_fk": { + "name": "coding_plan_terms_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_terms_credit_transaction_id_credit_transactions_id_fk": { + "name": "coding_plan_terms_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_terms_kind_check": { + "name": "coding_plan_terms_kind_check", + "value": "\"coding_plan_terms\".\"kind\" IN ('activation', 'extension', 'renewal')" + } + }, + "isRLSEnabled": false + }, + "public.container_usage_interval": { + "name": "container_usage_interval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_epoch_ms": { + "name": "start_epoch_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cloud_billing_sku_id": { + "name": "cloud_billing_sku_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_fingerprint": { + "name": "context_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat_seq": { + "name": "last_heartbeat_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confirmed_seconds": { + "name": "confirmed_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "final_stop_seq": { + "name": "final_stop_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_container_usage_interval_sweep": { + "name": "IDX_container_usage_interval_sweep", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_container_usage_interval_subject_started": { + "name": "IDX_container_usage_interval_subject_started", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_container_usage_interval_single_open": { + "name": "UQ_container_usage_interval_single_open", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"container_usage_interval\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_usage_interval_cloud_billing_sku_id_cloud_billing_sku_id_fk": { + "name": "container_usage_interval_cloud_billing_sku_id_cloud_billing_sku_id_fk", + "tableFrom": "container_usage_interval", + "tableTo": "cloud_billing_sku", + "columnsFrom": [ + "cloud_billing_sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "container_usage_interval_subject_type": { + "name": "container_usage_interval_subject_type", + "value": "\"container_usage_interval\".\"subject_type\" IN ('user', 'org')" + }, + "container_usage_interval_actor_type": { + "name": "container_usage_interval_actor_type", + "value": "\"container_usage_interval\".\"actor_type\" IN ('user', 'bot')" + }, + "container_usage_interval_context_fingerprint": { + "name": "container_usage_interval_context_fingerprint", + "value": "\"container_usage_interval\".\"context_fingerprint\" ~ '^[a-f0-9]{64}$'" + }, + "container_usage_interval_attribution": { + "name": "container_usage_interval_attribution", + "value": "\"container_usage_interval\".\"actor_type\" = 'bot' OR (\"container_usage_interval\".\"actor_type\" = 'user' AND (\"container_usage_interval\".\"subject_type\" <> 'user' OR \"container_usage_interval\".\"actor_id\" = \"container_usage_interval\".\"subject_id\"))" + }, + "container_usage_interval_status": { + "name": "container_usage_interval_status", + "value": "\"container_usage_interval\".\"status\" IN ('open', 'closed')" + }, + "container_usage_interval_open_closed_shape": { + "name": "container_usage_interval_open_closed_shape", + "value": "(\"container_usage_interval\".\"status\" = 'open' AND \"container_usage_interval\".\"stopped_at\" IS NULL AND \"container_usage_interval\".\"close_reason\" IS NULL) OR (\"container_usage_interval\".\"status\" = 'closed' AND \"container_usage_interval\".\"stopped_at\" IS NOT NULL AND \"container_usage_interval\".\"close_reason\" IS NOT NULL)" + }, + "container_usage_interval_time_order": { + "name": "container_usage_interval_time_order", + "value": "\"container_usage_interval\".\"last_seen_at\" >= \"container_usage_interval\".\"started_at\" AND (\"container_usage_interval\".\"stopped_at\" IS NULL OR (\"container_usage_interval\".\"stopped_at\" >= \"container_usage_interval\".\"started_at\" AND \"container_usage_interval\".\"stopped_at\" <= \"container_usage_interval\".\"last_seen_at\"))" + }, + "container_usage_interval_last_heartbeat_seq_nonnegative": { + "name": "container_usage_interval_last_heartbeat_seq_nonnegative", + "value": "\"container_usage_interval\".\"last_heartbeat_seq\" >= 0" + }, + "container_usage_interval_confirmed_seconds_nonnegative": { + "name": "container_usage_interval_confirmed_seconds_nonnegative", + "value": "\"container_usage_interval\".\"confirmed_seconds\" >= 0" + }, + "container_usage_interval_final_stop_seq_positive": { + "name": "container_usage_interval_final_stop_seq_positive", + "value": "\"container_usage_interval\".\"final_stop_seq\" IS NULL OR \"container_usage_interval\".\"final_stop_seq\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.container_usage_segment": { + "name": "container_usage_segment", + "schema": "", + "columns": { + "interval_id": { + "name": "interval_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_seconds": { + "name": "reported_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "usage_seconds": { + "name": "usage_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_container_usage_segment_received": { + "name": "IDX_container_usage_segment_received", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_usage_segment_interval_id_container_usage_interval_id_fk": { + "name": "container_usage_segment_interval_id_container_usage_interval_id_fk", + "tableFrom": "container_usage_segment", + "tableTo": "container_usage_interval", + "columnsFrom": [ + "interval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "container_usage_segment_interval_id_seq_pk": { + "name": "container_usage_segment_interval_id_seq_pk", + "columns": [ + "interval_id", + "seq" + ] + } + }, + "uniqueConstraints": { + "container_usage_segment_idempotency_key_unique": { + "name": "container_usage_segment_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "container_usage_segment_seq_positive": { + "name": "container_usage_segment_seq_positive", + "value": "\"container_usage_segment\".\"seq\" > 0" + }, + "container_usage_segment_reported_seconds_nonnegative": { + "name": "container_usage_segment_reported_seconds_nonnegative", + "value": "\"container_usage_segment\".\"reported_seconds\" >= 0" + }, + "container_usage_segment_usage_seconds_nonnegative": { + "name": "container_usage_segment_usage_seconds_nonnegative", + "value": "\"container_usage_segment\".\"usage_seconds\" >= 0" + }, + "container_usage_segment_usage_within_reported": { + "name": "container_usage_segment_usage_within_reported", + "value": "\"container_usage_segment\".\"usage_seconds\" <= \"container_usage_segment\".\"reported_seconds\"" + } + }, + "isRLSEnabled": false + }, + "public.contributor_champion_contributors": { + "name": "contributor_champion_contributors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_profile_url": { + "name": "github_profile_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "first_contribution_at": { + "name": "first_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contribution_at": { + "name": "last_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "all_time_contributions": { + "name": "all_time_contributions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "manual_email": { + "name": "manual_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_contributors_last_contribution_at": { + "name": "IDX_contributor_champion_contributors_last_contribution_at", + "columns": [ + { + "expression": "last_contribution_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_contributors_manual_email": { + "name": "IDX_contributor_champion_contributors_manual_email", + "columns": [ + { + "expression": "manual_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_contributors_github_login": { + "name": "UQ_contributor_champion_contributors_github_login", + "nullsNotDistinct": false, + "columns": [ + "github_login" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_events": { + "name": "contributor_champion_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_pr_url": { + "name": "github_pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_title": { + "name": "github_pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_login": { + "name": "github_author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_email": { + "name": "github_author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_events_contributor_id": { + "name": "IDX_contributor_champion_events_contributor_id", + "columns": [ + { + "expression": "contributor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_merged_at": { + "name": "IDX_contributor_champion_events_merged_at", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_author_email": { + "name": "IDX_contributor_champion_events_author_email", + "columns": [ + { + "expression": "github_author_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_events", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_events_repo_pr": { + "name": "UQ_contributor_champion_events_repo_pr", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "github_pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_memberships": { + "name": "contributor_champion_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_tier": { + "name": "selected_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_tier": { + "name": "enrolled_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_amount_microdollars": { + "name": "credit_amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credits_last_granted_at": { + "name": "credits_last_granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "linked_kilo_user_id": { + "name": "linked_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_memberships_credits_due": { + "name": "IDX_contributor_champion_memberships_credits_due", + "columns": [ + { + "expression": "credits_last_granted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NOT NULL AND \"contributor_champion_memberships\".\"credit_amount_microdollars\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_memberships_linked_kilo_user_id": { + "name": "IDX_contributor_champion_memberships_linked_kilo_user_id", + "columns": [ + { + "expression": "linked_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk": { + "name": "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "kilocode_users", + "columnsFrom": [ + "linked_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_memberships_contributor_id": { + "name": "UQ_contributor_champion_memberships_contributor_id", + "nullsNotDistinct": false, + "columns": [ + "contributor_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contributor_champion_memberships_selected_tier_check": { + "name": "contributor_champion_memberships_selected_tier_check", + "value": "\"contributor_champion_memberships\".\"selected_tier\" IS NULL OR \"contributor_champion_memberships\".\"selected_tier\" IN ('contributor', 'ambassador', 'champion')" + }, + "contributor_champion_memberships_enrolled_tier_check": { + "name": "contributor_champion_memberships_enrolled_tier_check", + "value": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NULL OR \"contributor_champion_memberships\".\"enrolled_tier\" IN ('contributor', 'ambassador', 'champion')" + } + }, + "isRLSEnabled": false + }, + "public.contributor_champion_sync_state": { + "name": "contributor_champion_sync_state", + "schema": "", + "columns": { + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_merged_at": { + "name": "last_merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_campaigns": { + "name": "credit_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_expiry_hours": { + "name": "credit_expiry_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_ends_at": { + "name": "campaign_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_redemptions_allowed": { + "name": "total_redemptions_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_credit_campaigns_slug": { + "name": "UQ_credit_campaigns_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_credit_campaigns_credit_category": { + "name": "UQ_credit_campaigns_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_campaigns_slug_format_check": { + "name": "credit_campaigns_slug_format_check", + "value": "\"credit_campaigns\".\"slug\" ~ '^[a-z0-9-]{5,40}$'" + }, + "credit_campaigns_amount_positive_check": { + "name": "credit_campaigns_amount_positive_check", + "value": "\"credit_campaigns\".\"amount_microdollars\" > 0" + }, + "credit_campaigns_credit_expiry_hours_positive_check": { + "name": "credit_campaigns_credit_expiry_hours_positive_check", + "value": "\"credit_campaigns\".\"credit_expiry_hours\" IS NULL OR \"credit_campaigns\".\"credit_expiry_hours\" > 0" + }, + "credit_campaigns_total_redemptions_allowed_positive_check": { + "name": "credit_campaigns_total_redemptions_allowed_positive_check", + "value": "\"credit_campaigns\".\"total_redemptions_allowed\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.credit_transactions": { + "name": "credit_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expiration_baseline_microdollars_used": { + "name": "expiration_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "original_baseline_microdollars_used": { + "name": "original_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coinbase_credit_block_id": { + "name": "coinbase_credit_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_category_uniqueness": { + "name": "check_category_uniqueness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_credit_transactions_created_at": { + "name": "IDX_credit_transactions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_is_free": { + "name": "IDX_credit_transactions_is_free", + "columns": [ + { + "expression": "is_free", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_kilo_user_id": { + "name": "IDX_credit_transactions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_credit_category": { + "name": "IDX_credit_transactions_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_stripe_payment_id": { + "name": "IDX_credit_transactions_stripe_payment_id", + "columns": [ + { + "expression": "stripe_payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_original_transaction_id": { + "name": "IDX_credit_transactions_original_transaction_id", + "columns": [ + { + "expression": "original_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_coinbase_credit_block_id": { + "name": "IDX_credit_transactions_coinbase_credit_block_id", + "columns": [ + { + "expression": "coinbase_credit_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_organization_id": { + "name": "IDX_credit_transactions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_unique_category": { + "name": "IDX_credit_transactions_unique_category", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_transactions\".\"check_category_uniqueness\" = TRUE", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_transactions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "credit_transactions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "credit_transactions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_llm2": { + "name": "custom_llm2", + "schema": "", + "columns": { + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deleted_user_email_tombstones": { + "name": "deleted_user_email_tombstones", + "schema": "", + "columns": { + "normalized_email_hash": { + "name": "normalized_email_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_builds": { + "name": "deployment_builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_builds_deployment_id": { + "name": "idx_deployment_builds_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_builds_status": { + "name": "idx_deployment_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_builds_deployment_id_deployments_id_fk": { + "name": "deployment_builds_deployment_id_deployments_id_fk", + "tableFrom": "deployment_builds", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_env_vars": { + "name": "deployment_env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_env_vars_deployment_id": { + "name": "idx_deployment_env_vars_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_env_vars_deployment_id_deployments_id_fk": { + "name": "deployment_env_vars_deployment_id_deployments_id_fk", + "tableFrom": "deployment_env_vars", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployment_env_vars_deployment_key": { + "name": "UQ_deployment_env_vars_deployment_key", + "nullsNotDistinct": false, + "columns": [ + "deployment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_events": { + "name": "deployment_events", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'log'" + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_deployment_events_build_id": { + "name": "idx_deployment_events_build_id", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_timestamp": { + "name": "idx_deployment_events_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_type": { + "name": "idx_deployment_events_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_events_build_id_deployment_builds_id_fk": { + "name": "deployment_events_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_events", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_events_build_id_event_id_pk": { + "name": "deployment_events_build_id_event_id_pk", + "columns": [ + "build_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_threat_detections": { + "name": "deployment_threat_detections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "threat_type": { + "name": "threat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_threat_detections_deployment_id": { + "name": "idx_deployment_threat_detections_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_threat_detections_created_at": { + "name": "idx_deployment_threat_detections_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_threat_detections_deployment_id_deployments_id_fk": { + "name": "deployment_threat_detections_deployment_id_deployments_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_threat_detections_build_id_deployment_builds_id_fk": { + "name": "deployment_threat_detections_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_source": { + "name": "repository_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_url": { + "name": "deployment_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "git_auth_token": { + "name": "git_auth_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_deployed_at": { + "name": "last_deployed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_build_id": { + "name": "last_build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threat_status": { + "name": "threat_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_owned_by_user_id": { + "name": "idx_deployments_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_owned_by_organization_id": { + "name": "idx_deployments_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_platform_integration_id": { + "name": "idx_deployments_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_repository_source_branch": { + "name": "idx_deployments_repository_source_branch", + "columns": [ + { + "expression": "repository_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_threat_status_pending": { + "name": "idx_deployments_threat_status_pending", + "columns": [ + { + "expression": "threat_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"deployments\".\"threat_status\" = 'pending_scan'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deployments_owned_by_organization_id_organizations_id_fk": { + "name": "deployments_owned_by_organization_id_organizations_id_fk", + "tableFrom": "deployments", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_deployment_slug": { + "name": "UQ_deployments_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_owner_check": { + "name": "deployments_owner_check", + "value": "(\n (\"deployments\".\"owned_by_user_id\" IS NOT NULL AND \"deployments\".\"owned_by_organization_id\" IS NULL) OR\n (\"deployments\".\"owned_by_user_id\" IS NULL AND \"deployments\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "deployments_source_type_check": { + "name": "deployments_source_type_check", + "value": "\"deployments\".\"source_type\" IN ('github', 'git', 'app-builder')" + } + }, + "isRLSEnabled": false + }, + "public.deployments_ephemeral": { + "name": "deployments_ephemeral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_cleanup_at": { + "name": "next_cleanup_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleanup_claim_token": { + "name": "cleanup_claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cleanup_claimed_until": { + "name": "cleanup_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployments_ephemeral_owned_by_user_id": { + "name": "idx_deployments_ephemeral_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_ephemeral_next_cleanup_at": { + "name": "idx_deployments_ephemeral_next_cleanup_at", + "columns": [ + { + "expression": "next_cleanup_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_ephemeral_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_ephemeral_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments_ephemeral", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_ephemeral_internal_worker_name": { + "name": "UQ_deployments_ephemeral_internal_worker_name", + "nullsNotDistinct": false, + "columns": [ + "internal_worker_name" + ] + }, + "UQ_deployments_ephemeral_deployment_slug": { + "name": "UQ_deployments_ephemeral_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_ephemeral_source_type_check": { + "name": "deployments_ephemeral_source_type_check", + "value": "\"deployments_ephemeral\".\"source_type\" IN ('html')" + }, + "deployments_ephemeral_status_check": { + "name": "deployments_ephemeral_status_check", + "value": "\"deployments_ephemeral\".\"status\" IN ('pending', 'active', 'cleanup_retry')" + }, + "deployments_ephemeral_claim_fields_check": { + "name": "deployments_ephemeral_claim_fields_check", + "value": "(\"deployments_ephemeral\".\"cleanup_claim_token\" IS NULL) = (\"deployments_ephemeral\".\"cleanup_claimed_until\" IS NULL)" + }, + "deployments_ephemeral_active_fields_check": { + "name": "deployments_ephemeral_active_fields_check", + "value": "\"deployments_ephemeral\".\"status\" <> 'active' OR (\"deployments_ephemeral\".\"deployment_slug\" IS NOT NULL AND \"deployments_ephemeral\".\"expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.device_auth_requests": { + "name": "device_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_device_auth_requests_code": { + "name": "UQ_device_auth_requests_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_status": { + "name": "IDX_device_auth_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_expires_at": { + "name": "IDX_device_auth_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_kilo_user_id": { + "name": "IDX_device_auth_requests_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_device_auth_requests_device_code_hash": { + "name": "UQ_device_auth_requests_device_code_hash", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_auth_requests\".\"device_code_hash\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_user_code": { + "name": "IDX_device_auth_requests_user_code", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"device_auth_requests\".\"user_code\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_auth_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "device_auth_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_auth_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_refresh_tokens": { + "name": "device_refresh_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_session_id": { + "name": "device_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_device_refresh_tokens_device_session_id": { + "name": "IDX_device_refresh_tokens_device_session_id", + "columns": [ + { + "expression": "device_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_refresh_tokens_expires_at": { + "name": "IDX_device_refresh_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_refresh_tokens_device_session_id_device_sessions_id_fk": { + "name": "device_refresh_tokens_device_session_id_device_sessions_id_fk", + "tableFrom": "device_refresh_tokens", + "tableTo": "device_sessions", + "columnsFrom": [ + "device_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_sessions": { + "name": "device_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_auth_request_id": { + "name": "device_auth_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_device_sessions_kilo_user_id": { + "name": "IDX_device_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_sessions_revoked_at": { + "name": "IDX_device_sessions_revoked_at", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "device_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_listener": { + "name": "discord_gateway_listener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.editor_name": { + "name": "editor_name", + "schema": "", + "columns": { + "editor_name_id": { + "name": "editor_name_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_editor_name": { + "name": "UQ_editor_name", + "columns": [ + { + "expression": "editor_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_data": { + "name": "enrichment_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_enrichment_data": { + "name": "github_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linkedin_enrichment_data": { + "name": "linkedin_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "clay_enrichment_data": { + "name": "clay_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_enrichment_data_user_id": { + "name": "IDX_enrichment_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_data_user_id_kilocode_users_id_fk": { + "name": "enrichment_data_user_id_kilocode_users_id_fk", + "tableFrom": "enrichment_data", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_enrichment_data_user_id": { + "name": "UQ_enrichment_data_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_monthly_usage": { + "name": "exa_monthly_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_charged_microdollars": { + "name": "total_charged_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "free_allowance_microdollars": { + "name": "free_allowance_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 10000000 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_monthly_usage_personal": { + "name": "idx_exa_monthly_usage_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_exa_monthly_usage_org": { + "name": "idx_exa_monthly_usage_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_usage_log": { + "name": "exa_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "charged_to_balance": { + "name": "charged_to_balance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_usage_log_user_created": { + "name": "idx_exa_usage_log_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "exa_usage_log_id_created_at_pk": { + "name": "exa_usage_log_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feature": { + "name": "feature", + "schema": "", + "columns": { + "feature_id": { + "name": "feature_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_feature": { + "name": "UQ_feature", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finish_reason": { + "name": "finish_reason", + "schema": "", + "columns": { + "finish_reason_id": { + "name": "finish_reason_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_finish_reason": { + "name": "UQ_finish_reason", + "columns": [ + { + "expression": "finish_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_model_usage": { + "name": "free_model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_model_usage_ip_created_at": { + "name": "idx_free_model_usage_ip_created_at", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_created_at": { + "name": "idx_free_model_usage_created_at", + "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.github_branch_pull_requests": { + "name": "github_branch_pull_requests", + "schema": "", + "columns": { + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_sha": { + "name": "pr_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_review_decision": { + "name": "pr_review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_decision_pending": { + "name": "review_decision_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_decision_fetching_at": { + "name": "review_decision_fetching_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pr_last_synced_at": { + "name": "pr_last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_github_branch_prs_org": { + "name": "UQ_github_branch_prs_org", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"github_branch_pull_requests\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_github_branch_prs_user": { + "name": "UQ_github_branch_prs_user", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"github_branch_pull_requests\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_branch_pull_requests_owned_by_organization_id_organizations_id_fk": { + "name": "github_branch_pull_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "github_branch_pull_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_branch_pull_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "github_branch_pull_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "github_branch_pull_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_branch_pull_requests_owner_check": { + "name": "github_branch_pull_requests_owner_check", + "value": "(\n (\"github_branch_pull_requests\".\"owned_by_organization_id\" IS NOT NULL AND \"github_branch_pull_requests\".\"owned_by_user_id\" IS NULL) OR\n (\"github_branch_pull_requests\".\"owned_by_organization_id\" IS NULL AND \"github_branch_pull_requests\".\"owned_by_user_id\" IS NOT NULL)\n )" + }, + "github_branch_pull_requests_review_decision_check": { + "name": "github_branch_pull_requests_review_decision_check", + "value": "\"github_branch_pull_requests\".\"pr_review_decision\" IS NULL OR \"github_branch_pull_requests\".\"pr_review_decision\" IN ('approved', 'changes_requested', 'review_required')" + } + }, + "isRLSEnabled": false + }, + "public.github_install_states": { + "name": "github_install_states", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_github_install_states_expires_at": { + "name": "IDX_github_install_states_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_install_states_kilo_user_id_kilocode_users_id_fk": { + "name": "github_install_states_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "github_install_states", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_install_states_owner_type_check": { + "name": "github_install_states_owner_type_check", + "value": "\"github_install_states\".\"owner_type\" IN ('org', 'user')" + } + }, + "isRLSEnabled": false + }, + "public.http_ip": { + "name": "http_ip", + "schema": "", + "columns": { + "http_ip_id": { + "name": "http_ip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_ip": { + "name": "http_ip", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_ip": { + "name": "UQ_http_ip", + "columns": [ + { + "expression": "http_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_user_agent": { + "name": "http_user_agent", + "schema": "", + "columns": { + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_user_agent": { + "name": "UQ_http_user_agent", + "columns": [ + { + "expression": "http_user_agent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impact_advocate_participants": { + "name": "impact_advocate_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_id": { + "name": "advocate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_account_id": { + "name": "advocate_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_referral_identifier": { + "name": "opaque_referral_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_state": { + "name": "registration_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_registration_attempt_at": { + "name": "last_registration_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_impact_advocate_participants_program_referral_identifier": { + "name": "UQ_impact_advocate_participants_program_referral_identifier", + "columns": [ + { + "expression": "program_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "opaque_referral_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"impact_advocate_participants\".\"opaque_referral_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_participants_registration_state": { + "name": "IDX_impact_advocate_participants_registration_state", + "columns": [ + { + "expression": "registration_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_participants_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_participants_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_participants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_participants_program_user": { + "name": "UQ_impact_advocate_participants_program_user", + "nullsNotDistinct": false, + "columns": [ + "program_key", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_participants_program_key_check": { + "name": "impact_advocate_participants_program_key_check", + "value": "\"impact_advocate_participants\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_advocate_participants_registration_state_check": { + "name": "impact_advocate_participants_registration_state_check", + "value": "\"impact_advocate_participants\".\"registration_state\" IN ('pending', 'retrying', 'registered', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_registration_attempts": { + "name": "impact_advocate_registration_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_cookie_value": { + "name": "opaque_cookie_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_value_length": { + "name": "cookie_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_registration_attempts_participant_id": { + "name": "IDX_impact_advocate_registration_attempts_participant_id", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_registration_attempts_delivery_state": { + "name": "IDX_impact_advocate_registration_attempts_delivery_state", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk": { + "name": "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk", + "tableFrom": "impact_advocate_registration_attempts", + "tableTo": "impact_advocate_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_registration_attempts_dedupe_key": { + "name": "UQ_impact_advocate_registration_attempts_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_registration_attempts_program_key_check": { + "name": "impact_advocate_registration_attempts_program_key_check", + "value": "\"impact_advocate_registration_attempts\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_advocate_registration_attempts_delivery_state_check": { + "name": "impact_advocate_registration_attempts_delivery_state_check", + "value": "\"impact_advocate_registration_attempts\".\"delivery_state\" IN ('queued', 'sending', 'succeeded', 'failed')" + }, + "impact_advocate_registration_attempts_cookie_value_length_non_negative_check": { + "name": "impact_advocate_registration_attempts_cookie_value_length_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"cookie_value_length\" >= 0" + }, + "impact_advocate_registration_attempts_attempt_count_non_negative_check": { + "name": "impact_advocate_registration_attempts_attempt_count_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_reward_redemptions": { + "name": "impact_advocate_reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "impact_reward_id": { + "name": "impact_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lookup_response_payload": { + "name": "lookup_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "redeem_response_payload": { + "name": "redeem_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_reward_redemptions_beneficiary_user_id": { + "name": "IDX_impact_advocate_reward_redemptions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_reward_redemptions_state": { + "name": "IDX_impact_advocate_reward_redemptions_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_reward_redemptions_reward_id_impact_referral_rewards_id_fk": { + "name": "impact_advocate_reward_redemptions_reward_id_impact_referral_rewards_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "impact_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_reward_redemptions_reward_id": { + "name": "UQ_impact_advocate_reward_redemptions_reward_id", + "nullsNotDistinct": false, + "columns": [ + "reward_id" + ] + }, + "UQ_impact_advocate_reward_redemptions_dedupe_key": { + "name": "UQ_impact_advocate_reward_redemptions_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_reward_redemptions_state_check": { + "name": "impact_advocate_reward_redemptions_state_check", + "value": "\"impact_advocate_reward_redemptions\".\"state\" IN ('queued', 'retrying', 'redeemed', 'failed')" + }, + "impact_advocate_reward_redemptions_attempt_count_non_negative_check": { + "name": "impact_advocate_reward_redemptions_attempt_count_non_negative_check", + "value": "\"impact_advocate_reward_redemptions\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_attribution_touches": { + "name": "impact_attribution_touches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'kiloclaw'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touch_type": { + "name": "touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_tracking_value": { + "name": "opaque_tracking_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_value_length": { + "name": "tracking_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_tracking_value_accepted": { + "name": "is_tracking_value_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rs_code": { + "name": "rs_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_share_medium": { + "name": "rs_share_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_engagement_medium": { + "name": "rs_engagement_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "im_ref": { + "name": "im_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touched_at": { + "name": "touched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sale_attributed_at": { + "name": "sale_attributed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_attribution_touches_product_user_id": { + "name": "IDX_impact_attribution_touches_product_user_id", + "columns": [ + { + "expression": "product", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_user_id": { + "name": "IDX_impact_attribution_touches_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_anonymous_id": { + "name": "IDX_impact_attribution_touches_anonymous_id", + "columns": [ + { + "expression": "anonymous_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_expires_at": { + "name": "IDX_impact_attribution_touches_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_sale_attributed_at": { + "name": "IDX_impact_attribution_touches_sale_attributed_at", + "columns": [ + { + "expression": "sale_attributed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_attribution_touches_user_id_kilocode_users_id_fk": { + "name": "impact_attribution_touches_user_id_kilocode_users_id_fk", + "tableFrom": "impact_attribution_touches", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_attribution_touches_dedupe_key": { + "name": "UQ_impact_attribution_touches_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_attribution_touches_product_check": { + "name": "impact_attribution_touches_product_check", + "value": "\"impact_attribution_touches\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_attribution_touches_program_key_check": { + "name": "impact_attribution_touches_program_key_check", + "value": "\"impact_attribution_touches\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_attribution_touches_touch_type_check": { + "name": "impact_attribution_touches_touch_type_check", + "value": "\"impact_attribution_touches\".\"touch_type\" IN ('affiliate', 'referral')" + }, + "impact_attribution_touches_provider_check": { + "name": "impact_attribution_touches_provider_check", + "value": "\"impact_attribution_touches\".\"provider\" IN ('impact_performance', 'impact_advocate')" + }, + "impact_attribution_touches_tracking_value_length_non_negative_check": { + "name": "impact_attribution_touches_tracking_value_length_non_negative_check", + "value": "\"impact_attribution_touches\".\"tracking_value_length\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_conversion_reports": { + "name": "impact_conversion_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_tracker_id": { + "name": "action_tracker_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_conversion_reports_conversion_id": { + "name": "IDX_impact_conversion_reports_conversion_id", + "columns": [ + { + "expression": "conversion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_conversion_reports_state": { + "name": "IDX_impact_conversion_reports_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_conversion_reports_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_conversion_reports_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_conversion_reports", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_conversion_reports_dedupe_key": { + "name": "UQ_impact_conversion_reports_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_conversion_reports_state_check": { + "name": "impact_conversion_reports_state_check", + "value": "\"impact_conversion_reports\".\"state\" IN ('queued', 'retrying', 'delivered', 'failed')" + }, + "impact_conversion_reports_attempt_count_non_negative_check": { + "name": "impact_conversion_reports_attempt_count_non_negative_check", + "value": "\"impact_conversion_reports\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_conversions": { + "name": "impact_referral_conversions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "winning_touch_type": { + "name": "winning_touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credits'" + }, + "source_payment_id": { + "name": "source_payment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qualified": { + "name": "qualified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disqualification_reason": { + "name": "disqualification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_conversions_referee_user_id": { + "name": "IDX_impact_referral_conversions_referee_user_id", + "columns": [ + { + "expression": "referee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_conversions_referrer_user_id": { + "name": "IDX_impact_referral_conversions_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_conversions_referee_user_id_kilocode_users_id_fk": { + "name": "impact_referral_conversions_referee_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_conversions_referrer_user_id_kilocode_users_id_fk": { + "name": "impact_referral_conversions_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "impact_referral_conversions_source_touch_id_impact_attribution_touches_id_fk": { + "name": "impact_referral_conversions_source_touch_id_impact_attribution_touches_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "impact_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_conversions_product_payment_source": { + "name": "UQ_impact_referral_conversions_product_payment_source", + "nullsNotDistinct": false, + "columns": [ + "product", + "payment_provider", + "source_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_conversions_product_check": { + "name": "impact_referral_conversions_product_check", + "value": "\"impact_referral_conversions\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_conversions_winning_touch_type_check": { + "name": "impact_referral_conversions_winning_touch_type_check", + "value": "\"impact_referral_conversions\".\"winning_touch_type\" IN ('referral', 'affiliate', 'none')" + }, + "impact_referral_conversions_payment_provider_check": { + "name": "impact_referral_conversions_payment_provider_check", + "value": "\"impact_referral_conversions\".\"payment_provider\" IN ('stripe', 'credits', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_reward_applications": { + "name": "impact_referral_reward_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "previous_renewal_boundary": { + "name": "previous_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "new_renewal_boundary": { + "name": "new_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "local_operation_id": { + "name": "local_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_operation_id": { + "name": "stripe_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_idempotency_key": { + "name": "stripe_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_reward_applications_reward_id": { + "name": "IDX_impact_referral_reward_applications_reward_id", + "columns": [ + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_reward_applications_beneficiary_user_id": { + "name": "IDX_impact_referral_reward_applications_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_reward_applications_reward_id_impact_referral_rewards_id_fk": { + "name": "impact_referral_reward_applications_reward_id_impact_referral_rewards_id_fk", + "tableFrom": "impact_referral_reward_applications", + "tableTo": "impact_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_reward_applications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "impact_referral_reward_applications_product_check": { + "name": "impact_referral_reward_applications_product_check", + "value": "\"impact_referral_reward_applications\".\"product\" IN ('kiloclaw', 'kilo_pass')" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_reward_decisions": { + "name": "impact_referral_reward_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_kind": { + "name": "reward_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw_free_month'" + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reward_percent": { + "name": "reward_percent", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "source_tier": { + "name": "source_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount_usd": { + "name": "reward_amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_reward_decisions_beneficiary_user_id": { + "name": "IDX_impact_referral_reward_decisions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_reward_decisions_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_referral_reward_decisions_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_referral_reward_decisions", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_reward_decisions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_reward_decisions_conversion_role": { + "name": "UQ_impact_referral_reward_decisions_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_reward_decisions_product_check": { + "name": "impact_referral_reward_decisions_product_check", + "value": "\"impact_referral_reward_decisions\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_reward_decisions_beneficiary_role_check": { + "name": "impact_referral_reward_decisions_beneficiary_role_check", + "value": "\"impact_referral_reward_decisions\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "impact_referral_reward_decisions_outcome_check": { + "name": "impact_referral_reward_decisions_outcome_check", + "value": "\"impact_referral_reward_decisions\".\"outcome\" IN ('granted', 'cap_limited', 'disqualified')" + }, + "impact_referral_reward_decisions_reward_kind_check": { + "name": "impact_referral_reward_decisions_reward_kind_check", + "value": "\"impact_referral_reward_decisions\".\"reward_kind\" IN ('kiloclaw_free_month', 'kilo_pass_bonus')" + }, + "impact_referral_reward_decisions_months_granted_non_negative_check": { + "name": "impact_referral_reward_decisions_months_granted_non_negative_check", + "value": "\"impact_referral_reward_decisions\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_rewards": { + "name": "impact_referral_rewards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_kind": { + "name": "reward_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw_free_month'" + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "reward_percent": { + "name": "reward_percent", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "source_tier": { + "name": "source_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount_usd": { + "name": "reward_amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applies_to_subscription_id": { + "name": "applies_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applies_to_kilo_pass_subscription_id": { + "name": "applies_to_kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consumed_kilo_pass_issuance_id": { + "name": "consumed_kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consumed_kilo_pass_issuance_item_id": { + "name": "consumed_kilo_pass_issuance_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "earned_at": { + "name": "earned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reversed_at": { + "name": "reversed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_rewards_beneficiary_user_id": { + "name": "IDX_impact_referral_rewards_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_rewards_status": { + "name": "IDX_impact_referral_rewards_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_rewards_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_referral_rewards_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_rewards_decision_id_impact_referral_reward_decisions_id_fk": { + "name": "impact_referral_rewards_decision_id_impact_referral_reward_decisions_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "impact_referral_reward_decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_rewards_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_rewards_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_subscription": { + "name": "FK_impact_referral_rewards_kilo_pass_subscription", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "applies_to_kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_issuance": { + "name": "FK_impact_referral_rewards_kilo_pass_issuance", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "consumed_kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_issuance_item": { + "name": "FK_impact_referral_rewards_kilo_pass_issuance_item", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_issuance_items", + "columnsFrom": [ + "consumed_kilo_pass_issuance_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_rewards_conversion_role": { + "name": "UQ_impact_referral_rewards_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + }, + "UQ_impact_referral_rewards_decision_id": { + "name": "UQ_impact_referral_rewards_decision_id", + "nullsNotDistinct": false, + "columns": [ + "decision_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_rewards_product_check": { + "name": "impact_referral_rewards_product_check", + "value": "\"impact_referral_rewards\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_rewards_beneficiary_role_check": { + "name": "impact_referral_rewards_beneficiary_role_check", + "value": "\"impact_referral_rewards\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "impact_referral_rewards_reward_kind_check": { + "name": "impact_referral_rewards_reward_kind_check", + "value": "\"impact_referral_rewards\".\"reward_kind\" IN ('kiloclaw_free_month', 'kilo_pass_bonus')" + }, + "impact_referral_rewards_status_check": { + "name": "impact_referral_rewards_status_check", + "value": "\"impact_referral_rewards\".\"status\" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')" + }, + "impact_referral_rewards_months_granted_non_negative_check": { + "name": "impact_referral_rewards_months_granted_non_negative_check", + "value": "\"impact_referral_rewards\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referrals": { + "name": "impact_referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "impact_referral_id": { + "name": "impact_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referrals_referrer_user_id": { + "name": "IDX_impact_referrals_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referrals_source_touch_id": { + "name": "IDX_impact_referrals_source_touch_id", + "columns": [ + { + "expression": "source_touch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referrals_referee_user_id_kilocode_users_id_fk": { + "name": "impact_referrals_referee_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referrals_referrer_user_id_kilocode_users_id_fk": { + "name": "impact_referrals_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "impact_referrals_source_touch_id_impact_attribution_touches_id_fk": { + "name": "impact_referrals_source_touch_id_impact_attribution_touches_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "impact_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referrals_product_referee_user_id": { + "name": "UQ_impact_referrals_product_referee_user_id", + "nullsNotDistinct": false, + "columns": [ + "product", + "referee_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referrals_product_check": { + "name": "impact_referrals_product_check", + "value": "\"impact_referrals\".\"product\" IN ('kiloclaw', 'kilo_pass')" + } + }, + "isRLSEnabled": false + }, + "public.ja4_digest": { + "name": "ja4_digest", + "schema": "", + "columns": { + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ja4_digest": { + "name": "ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_ja4_digest": { + "name": "UQ_ja4_digest", + "columns": [ + { + "expression": "ja4_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_audit_log": { + "name": "kilo_pass_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_credit_transaction_id": { + "name": "related_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "related_monthly_issuance_id": { + "name": "related_monthly_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_kilo_pass_audit_log_created_at": { + "name": "IDX_kilo_pass_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_user_id": { + "name": "IDX_kilo_pass_audit_log_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_pass_subscription_id": { + "name": "IDX_kilo_pass_audit_log_kilo_pass_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_action": { + "name": "IDX_kilo_pass_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_result": { + "name": "IDX_kilo_pass_audit_log_result", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_idempotency_key": { + "name": "IDX_kilo_pass_audit_log_idempotency_key", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_event_id": { + "name": "IDX_kilo_pass_audit_log_stripe_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_invoice_id": { + "name": "IDX_kilo_pass_audit_log_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_subscription_id": { + "name": "IDX_kilo_pass_audit_log_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_credit_transaction_id": { + "name": "IDX_kilo_pass_audit_log_related_credit_transaction_id", + "columns": [ + { + "expression": "related_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_monthly_issuance_id": { + "name": "IDX_kilo_pass_audit_log_related_monthly_issuance_id", + "columns": [ + { + "expression": "related_monthly_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "credit_transactions", + "columnsFrom": [ + "related_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "related_monthly_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_audit_log_action_check": { + "name": "kilo_pass_audit_log_action_check", + "value": "\"kilo_pass_audit_log\".\"action\" IN ('stripe_webhook_received', 'kilo_pass_invoice_paid_handled', 'store_purchase_completed', 'store_notification_received', 'store_subscription_renewed', 'store_subscription_canceled', 'store_subscription_expired', 'store_subscription_refunded', 'base_credits_issued', 'bonus_credits_issued', 'bonus_credits_skipped_idempotent', 'first_month_50pct_promo_issued', 'yearly_monthly_base_cron_started', 'yearly_monthly_base_cron_completed', 'issue_yearly_remaining_credits', 'duplicate_card_subscription_canceled', 'yearly_monthly_bonus_cron_started', 'yearly_monthly_bonus_cron_completed')" + }, + "kilo_pass_audit_log_result_check": { + "name": "kilo_pass_audit_log_result_check", + "value": "\"kilo_pass_audit_log\".\"result\" IN ('success', 'skipped_idempotent', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuance_items": { + "name": "kilo_pass_issuance_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_issuance_id": { + "name": "kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "bonus_percent_applied": { + "name": "bonus_percent_applied", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_issuance_items_issuance_id": { + "name": "IDX_kilo_pass_issuance_items_issuance_id", + "columns": [ + { + "expression": "kilo_pass_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuance_items_credit_transaction_id": { + "name": "IDX_kilo_pass_issuance_items_credit_transaction_id", + "columns": [ + { + "expression": "credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_issuance_items_credit_transaction_id_unique": { + "name": "kilo_pass_issuance_items_credit_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credit_transaction_id" + ] + }, + "UQ_kilo_pass_issuance_items_issuance_kind": { + "name": "UQ_kilo_pass_issuance_items_issuance_kind", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_issuance_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuance_items_bonus_percent_applied_range_check": { + "name": "kilo_pass_issuance_items_bonus_percent_applied_range_check", + "value": "\"kilo_pass_issuance_items\".\"bonus_percent_applied\" IS NULL OR (\"kilo_pass_issuance_items\".\"bonus_percent_applied\" >= 0 AND \"kilo_pass_issuance_items\".\"bonus_percent_applied\" <= 1)" + }, + "kilo_pass_issuance_items_amount_usd_non_negative_check": { + "name": "kilo_pass_issuance_items_amount_usd_non_negative_check", + "value": "\"kilo_pass_issuance_items\".\"amount_usd\" >= 0" + }, + "kilo_pass_issuance_items_kind_check": { + "name": "kilo_pass_issuance_items_kind_check", + "value": "\"kilo_pass_issuance_items\".\"kind\" IN ('base', 'bonus', 'promo_first_month_50pct', 'referral_bonus')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuances": { + "name": "kilo_pass_issuances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_month": { + "name": "issue_month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initial_welcome_promo_eligibility_reason": { + "name": "initial_welcome_promo_eligibility_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_issuances_stripe_invoice_id": { + "name": "UQ_kilo_pass_issuances_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_issuances\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_subscription_id": { + "name": "IDX_kilo_pass_issuances_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_issue_month": { + "name": "IDX_kilo_pass_issuances_issue_month", + "columns": [ + { + "expression": "issue_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_issuances", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_issuances_subscription_issue_month": { + "name": "UQ_kilo_pass_issuances_subscription_issue_month", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_subscription_id", + "issue_month" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuances_issue_month_day_one_check": { + "name": "kilo_pass_issuances_issue_month_day_one_check", + "value": "EXTRACT(DAY FROM \"kilo_pass_issuances\".\"issue_month\") = 1" + }, + "kilo_pass_issuances_source_check": { + "name": "kilo_pass_issuances_source_check", + "value": "\"kilo_pass_issuances\".\"source\" IN ('stripe_invoice', 'app_store_transaction', 'google_play_transaction', 'cron')" + }, + "kilo_pass_issuances_initial_welcome_promo_reason_check": { + "name": "kilo_pass_issuances_initial_welcome_promo_reason_check", + "value": "\"kilo_pass_issuances\".\"initial_welcome_promo_eligibility_reason\" IN ('first_payment_fingerprint_claim', 'fingerprint_previously_claimed', 'missing_fingerprint', 'no_supported_fingerprint', 'no_positive_settlement', 'settlement_unresolved')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_agreements": { + "name": "kilo_pass_org_agreements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "parent_organization_id": { + "name": "parent_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "term_version_id": { + "name": "term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processing_condition": { + "name": "processing_condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "purchase_channel": { + "name": "purchase_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchased_pass_capacity": { + "name": "purchased_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "next_purchased_pass_capacity": { + "name": "next_purchased_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_capacity_effective_at": { + "name": "next_capacity_effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_from": { + "name": "paid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_until": { + "name": "paid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issuance_anchor_at": { + "name": "issuance_anchor_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_seat_add_on_item_id": { + "name": "provider_seat_add_on_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activation_provider_event_id": { + "name": "activation_provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_contract_id": { + "name": "external_contract_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_review_required_at": { + "name": "payment_review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_effective_at": { + "name": "cancellation_effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "manually_issued_through": { + "name": "manually_issued_through", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_agreements_one_non_ended_parent": { + "name": "UQ_kilo_pass_org_agreements_one_non_ended_parent", + "columns": [ + { + "expression": "parent_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_provider_subscription": { + "name": "UQ_kilo_pass_org_agreements_provider_subscription", + "columns": [ + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"provider_subscription_id\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_provider_seat_add_on_item": { + "name": "UQ_kilo_pass_org_agreements_provider_seat_add_on_item", + "columns": [ + { + "expression": "provider_seat_add_on_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"provider_seat_add_on_item_id\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_external_contract": { + "name": "UQ_kilo_pass_org_agreements_external_contract", + "columns": [ + { + "expression": "external_contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"external_contract_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_activation_provider_event": { + "name": "UQ_kilo_pass_org_agreements_activation_provider_event", + "columns": [ + { + "expression": "activation_provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"activation_provider_event_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_agreements_processing": { + "name": "IDX_kilo_pass_org_agreements_processing", + "columns": [ + { + "expression": "processing_condition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_agreements_parent_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_agreements_parent_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_agreements", + "tableTo": "organizations", + "columnsFrom": [ + "parent_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_agreements_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_agreements_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_agreements", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_agreements_purchased_capacity_non_negative_check": { + "name": "kilo_pass_org_agreements_purchased_capacity_non_negative_check", + "value": "\"kilo_pass_org_agreements\".\"purchased_pass_capacity\" >= 0" + }, + "kilo_pass_org_agreements_next_capacity_check": { + "name": "kilo_pass_org_agreements_next_capacity_check", + "value": "(\"kilo_pass_org_agreements\".\"next_purchased_pass_capacity\" IS NULL AND \"kilo_pass_org_agreements\".\"next_capacity_effective_at\" IS NULL) OR (\"kilo_pass_org_agreements\".\"next_purchased_pass_capacity\" >= 0 AND \"kilo_pass_org_agreements\".\"next_capacity_effective_at\" IS NOT NULL)" + }, + "kilo_pass_org_agreements_paid_interval_check": { + "name": "kilo_pass_org_agreements_paid_interval_check", + "value": "(\"kilo_pass_org_agreements\".\"paid_from\" IS NULL AND \"kilo_pass_org_agreements\".\"paid_until\" IS NULL) OR (\"kilo_pass_org_agreements\".\"paid_from\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"paid_until\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"paid_from\" < \"kilo_pass_org_agreements\".\"paid_until\")" + }, + "kilo_pass_org_agreements_state_check": { + "name": "kilo_pass_org_agreements_state_check", + "value": "\"kilo_pass_org_agreements\".\"state\" IN ('pending_payment', 'active', 'cancel_at_period_end', 'ended')" + }, + "kilo_pass_org_agreements_processing_condition_check": { + "name": "kilo_pass_org_agreements_processing_condition_check", + "value": "\"kilo_pass_org_agreements\".\"processing_condition\" IN ('ready', 'manual', 'blocked', 'overallocated', 'failed', 'suspended_for_review')" + }, + "kilo_pass_org_agreements_purchase_channel_check": { + "name": "kilo_pass_org_agreements_purchase_channel_check", + "value": "\"kilo_pass_org_agreements\".\"purchase_channel\" IN ('self_serve', 'manual')" + }, + "kilo_pass_org_agreements_cadence_check": { + "name": "kilo_pass_org_agreements_cadence_check", + "value": "\"kilo_pass_org_agreements\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_allocation_plan_rows": { + "name": "kilo_pass_org_allocation_plan_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "allocation_plan_id": { + "name": "allocation_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pass_capacity": { + "name": "pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_allocation_plan_rows_positive_container": { + "name": "IDX_kilo_pass_org_allocation_plan_rows_positive_container", + "columns": [ + { + "expression": "allocation_container_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kilo_pass_org_allocation_plan_rows\".\"pass_capacity\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_allocation_plan_rows_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk": { + "name": "kilo_pass_org_allocation_plan_rows_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk", + "tableFrom": "kilo_pass_org_allocation_plan_rows", + "tableTo": "kilo_pass_org_allocation_plans", + "columnsFrom": [ + "allocation_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_allocation_plan_rows_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_allocation_plan_rows_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_allocation_plan_rows", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_allocation_plan_rows_plan_container": { + "name": "UQ_kilo_pass_org_allocation_plan_rows_plan_container", + "nullsNotDistinct": false, + "columns": [ + "allocation_plan_id", + "allocation_container_organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_allocation_plan_rows_capacity_non_negative_check": { + "name": "kilo_pass_org_allocation_plan_rows_capacity_non_negative_check", + "value": "\"kilo_pass_org_allocation_plan_rows\".\"pass_capacity\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_allocation_plans": { + "name": "kilo_pass_org_allocation_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_window_start": { + "name": "effective_window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_allocation_plans_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_allocation_plans_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_allocation_plans", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_allocation_plans_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_allocation_plans_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_allocation_plans", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_allocation_plans_agreement_window": { + "name": "UQ_kilo_pass_org_allocation_plans_agreement_window", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "effective_window_start" + ] + }, + "UQ_kilo_pass_org_allocation_plans_agreement_version": { + "name": "UQ_kilo_pass_org_allocation_plans_agreement_version", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_allocation_plans_version_positive_check": { + "name": "kilo_pass_org_allocation_plans_version_positive_check", + "value": "\"kilo_pass_org_allocation_plans\".\"version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_audit_records": { + "name": "kilo_pass_org_audit_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_kilo_user_id": { + "name": "actor_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_json": { + "name": "before_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_json": { + "name": "after_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_audit_records_idempotency": { + "name": "UQ_kilo_pass_org_audit_records_idempotency", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_audit_records\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_audit_records_agreement_created": { + "name": "IDX_kilo_pass_org_audit_records_agreement_created", + "columns": [ + { + "expression": "agreement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_audit_records_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_audit_records_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_audit_records", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_audit_records_actor_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_audit_records_actor_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_audit_records", + "tableTo": "kilocode_users", + "columnsFrom": [ + "actor_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_org_issuance_snapshots": { + "name": "kilo_pass_org_issuance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "processing_run_id": { + "name": "processing_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "allocation_plan_id": { + "name": "allocation_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "term_version_id": { + "name": "term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "qualifying_spend_starts_at": { + "name": "qualifying_spend_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tranche_key": { + "name": "tranche_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allocated_pass_capacity": { + "name": "allocated_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "base_credit_microdollars": { + "name": "base_credit_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_credit_microdollars": { + "name": "bonus_credit_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "unlock_spend_microdollars": { + "name": "unlock_spend_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "qualifying_spend_microdollars": { + "name": "qualifying_spend_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bonus_mode": { + "name": "bonus_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bonus_unlocked_at": { + "name": "bonus_unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "repair_completed_at": { + "name": "repair_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_transaction_id": { + "name": "bonus_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_credit_transaction_id": { + "name": "base_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_issuance_snapshots_base_credit_transaction": { + "name": "UQ_kilo_pass_org_issuance_snapshots_base_credit_transaction", + "columns": [ + { + "expression": "base_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_issuance_snapshots\".\"base_credit_transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_issuance_snapshots_bonus_credit_transaction": { + "name": "UQ_kilo_pass_org_issuance_snapshots_bonus_credit_transaction", + "columns": [ + { + "expression": "bonus_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_issuance_snapshots\".\"bonus_credit_transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_issuance_snapshots_window": { + "name": "IDX_kilo_pass_org_issuance_snapshots_window", + "columns": [ + { + "expression": "agreement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_issuance_snapshots_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_processing_run_id_kilo_pass_org_processing_runs_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_processing_run_id_kilo_pass_org_processing_runs_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_processing_runs", + "columnsFrom": [ + "processing_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_allocation_plans", + "columnsFrom": [ + "allocation_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_bonus_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_bonus_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "credit_transactions", + "columnsFrom": [ + "bonus_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_base_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_base_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "credit_transactions", + "columnsFrom": [ + "base_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_issuance_snapshots_container_window_tranche": { + "name": "UQ_kilo_pass_org_issuance_snapshots_container_window_tranche", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "allocation_container_organization_id", + "window_start", + "tranche_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_issuance_snapshots_window_check": { + "name": "kilo_pass_org_issuance_snapshots_window_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"window_start\" < \"kilo_pass_org_issuance_snapshots\".\"window_end\"" + }, + "kilo_pass_org_issuance_snapshots_qualifying_spend_window_check": { + "name": "kilo_pass_org_issuance_snapshots_qualifying_spend_window_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"window_start\" <= \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_starts_at\" AND \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_starts_at\" < \"kilo_pass_org_issuance_snapshots\".\"window_end\"" + }, + "kilo_pass_org_issuance_snapshots_values_non_negative_check": { + "name": "kilo_pass_org_issuance_snapshots_values_non_negative_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"allocated_pass_capacity\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"base_credit_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"bonus_credit_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"unlock_spend_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_microdollars\" >= 0" + }, + "kilo_pass_org_issuance_snapshots_kind_check": { + "name": "kilo_pass_org_issuance_snapshots_kind_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"kind\" IN ('regular', 'bridge', 'supplement')" + }, + "kilo_pass_org_issuance_snapshots_bonus_mode_check": { + "name": "kilo_pass_org_issuance_snapshots_bonus_mode_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"bonus_mode\" IN ('after_base', 'upfront')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_notification_deliveries": { + "name": "kilo_pass_org_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "processing_run_id": { + "name": "processing_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_kilo_user_id": { + "name": "recipient_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_notification_deliveries_status": { + "name": "IDX_kilo_pass_org_notification_deliveries_status", + "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": { + "kilo_pass_org_notification_deliveries_processing_run_id_kilo_pass_org_processing_runs_id_fk": { + "name": "kilo_pass_org_notification_deliveries_processing_run_id_kilo_pass_org_processing_runs_id_fk", + "tableFrom": "kilo_pass_org_notification_deliveries", + "tableTo": "kilo_pass_org_processing_runs", + "columnsFrom": [ + "processing_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_notification_deliveries_recipient_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_notification_deliveries_recipient_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_notification_deliveries", + "tableTo": "kilocode_users", + "columnsFrom": [ + "recipient_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_notification_deliveries_run_recipient": { + "name": "UQ_kilo_pass_org_notification_deliveries_run_recipient", + "nullsNotDistinct": false, + "columns": [ + "processing_run_id", + "recipient_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_notification_deliveries_status_check": { + "name": "kilo_pass_org_notification_deliveries_status_check", + "value": "\"kilo_pass_org_notification_deliveries\".\"status\" IN ('pending', 'sending', 'sent', 'failed')" + }, + "kilo_pass_org_notification_deliveries_attempt_count_check": { + "name": "kilo_pass_org_notification_deliveries_attempt_count_check", + "value": "\"kilo_pass_org_notification_deliveries\".\"attempt_count\" >= 0" + }, + "kilo_pass_org_notification_deliveries_sent_check": { + "name": "kilo_pass_org_notification_deliveries_sent_check", + "value": "(\"kilo_pass_org_notification_deliveries\".\"status\" = 'sent' AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NOT NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NULL) OR (\"kilo_pass_org_notification_deliveries\".\"status\" = 'sending' AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NOT NULL) OR (\"kilo_pass_org_notification_deliveries\".\"status\" IN ('pending', 'failed') AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_processing_runs": { + "name": "kilo_pass_org_processing_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_processing_runs_state_lease": { + "name": "IDX_kilo_pass_org_processing_runs_state_lease", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_processing_runs_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_processing_runs_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_processing_runs", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_processing_runs_agreement_window": { + "name": "UQ_kilo_pass_org_processing_runs_agreement_window", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "window_start" + ] + }, + "UQ_kilo_pass_org_processing_runs_idempotency": { + "name": "UQ_kilo_pass_org_processing_runs_idempotency", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_processing_runs_window_check": { + "name": "kilo_pass_org_processing_runs_window_check", + "value": "\"kilo_pass_org_processing_runs\".\"window_start\" < \"kilo_pass_org_processing_runs\".\"window_end\"" + }, + "kilo_pass_org_processing_runs_attempt_count_non_negative_check": { + "name": "kilo_pass_org_processing_runs_attempt_count_non_negative_check", + "value": "\"kilo_pass_org_processing_runs\".\"attempt_count\" >= 0" + }, + "kilo_pass_org_processing_runs_state_check": { + "name": "kilo_pass_org_processing_runs_state_check", + "value": "\"kilo_pass_org_processing_runs\".\"state\" IN ('pending', 'running', 'succeeded', 'blocked', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_qualifying_spend_events": { + "name": "kilo_pass_org_qualifying_spend_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "issuance_snapshot_id": { + "name": "issuance_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spent_microdollars": { + "name": "spent_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_qualifying_spend_events_snapshot_occurred": { + "name": "IDX_kilo_pass_org_qualifying_spend_events_snapshot_occurred", + "columns": [ + { + "expression": "issuance_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_qualifying_spend_events_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "kilo_pass_org_issuance_snapshots", + "columnsFrom": [ + "issuance_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_qualifying_spend_events_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_qualifying_spend_events_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_qualifying_spend_events_snapshot_credit_transaction": { + "name": "UQ_kilo_pass_org_qualifying_spend_events_snapshot_credit_transaction", + "nullsNotDistinct": false, + "columns": [ + "issuance_snapshot_id", + "credit_transaction_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_qualifying_spend_events_amount_positive_check": { + "name": "kilo_pass_org_qualifying_spend_events_amount_positive_check", + "value": "\"kilo_pass_org_qualifying_spend_events\".\"spent_microdollars\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_supplements": { + "name": "kilo_pass_org_supplements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "issuance_snapshot_id": { + "name": "issuance_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_invoice_line_id": { + "name": "provider_invoice_line_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remaining_service_numerator": { + "name": "remaining_service_numerator", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "remaining_service_denominator": { + "name": "remaining_service_denominator", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_supplements_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk": { + "name": "kilo_pass_org_supplements_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk", + "tableFrom": "kilo_pass_org_supplements", + "tableTo": "kilo_pass_org_issuance_snapshots", + "columnsFrom": [ + "issuance_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_supplements_provider_invoice_line": { + "name": "UQ_kilo_pass_org_supplements_provider_invoice_line", + "nullsNotDistinct": false, + "columns": [ + "provider_invoice_line_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_supplements_ratio_check": { + "name": "kilo_pass_org_supplements_ratio_check", + "value": "\"kilo_pass_org_supplements\".\"remaining_service_numerator\" > 0 AND \"kilo_pass_org_supplements\".\"remaining_service_denominator\" > 0 AND \"kilo_pass_org_supplements\".\"remaining_service_numerator\" <= \"kilo_pass_org_supplements\".\"remaining_service_denominator\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_term_transitions": { + "name": "kilo_pass_org_term_transitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_term_version_id": { + "name": "from_term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_term_version_id": { + "name": "to_term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_term_transitions_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_term_transitions_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_from_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_term_transitions_from_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "from_term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_to_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_term_transitions_to_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "to_term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_term_transitions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_term_transitions_agreement_effective": { + "name": "UQ_kilo_pass_org_term_transitions_agreement_effective", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "effective_at" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_term_transitions_changes_version_check": { + "name": "kilo_pass_org_term_transitions_changes_version_check", + "value": "\"kilo_pass_org_term_transitions\".\"from_term_version_id\" <> \"kilo_pass_org_term_transitions\".\"to_term_version_id\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_term_versions": { + "name": "kilo_pass_org_term_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "version_key": { + "name": "version_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_microdollars_per_pass": { + "name": "billing_price_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "base_credit_microdollars_per_pass": { + "name": "base_credit_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_credit_microdollars_per_pass": { + "name": "bonus_credit_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "unlock_spend_microdollars_per_pass": { + "name": "unlock_spend_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_mode": { + "name": "bonus_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_term_versions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_term_versions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_term_versions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_term_versions_version_key": { + "name": "UQ_kilo_pass_org_term_versions_version_key", + "nullsNotDistinct": false, + "columns": [ + "version_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_term_versions_amounts_non_negative_check": { + "name": "kilo_pass_org_term_versions_amounts_non_negative_check", + "value": "\"kilo_pass_org_term_versions\".\"billing_price_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"base_credit_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"bonus_credit_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"unlock_spend_microdollars_per_pass\" >= 0" + }, + "kilo_pass_org_term_versions_tier_check": { + "name": "kilo_pass_org_term_versions_tier_check", + "value": "\"kilo_pass_org_term_versions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_org_term_versions_cadence_check": { + "name": "kilo_pass_org_term_versions_cadence_check", + "value": "\"kilo_pass_org_term_versions\".\"cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_org_term_versions_bonus_mode_check": { + "name": "kilo_pass_org_term_versions_bonus_mode_check", + "value": "\"kilo_pass_org_term_versions\".\"bonus_mode\" IN ('after_base', 'upfront')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_pause_events": { + "name": "kilo_pass_pause_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resumes_at": { + "name": "resumes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_pause_events_subscription_id": { + "name": "IDX_kilo_pass_pause_events_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_pause_events_one_open_per_sub": { + "name": "UQ_kilo_pass_pause_events_one_open_per_sub", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_pause_events", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_pause_events_resumed_at_after_paused_at_check": { + "name": "kilo_pass_pause_events_resumed_at_after_paused_at_check", + "value": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL OR \"kilo_pass_pause_events\".\"resumed_at\" >= \"kilo_pass_pause_events\".\"paused_at\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_scheduled_changes": { + "name": "kilo_pass_scheduled_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_tier": { + "name": "from_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_cadence": { + "name": "from_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_tier": { + "name": "to_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_cadence": { + "name": "to_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_scheduled_changes_kilo_user_id": { + "name": "IDX_kilo_pass_scheduled_changes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_status": { + "name": "IDX_kilo_pass_scheduled_changes_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_stripe_subscription_id": { + "name": "IDX_kilo_pass_scheduled_changes_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id": { + "name": "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_scheduled_changes\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_effective_at": { + "name": "IDX_kilo_pass_scheduled_changes_effective_at", + "columns": [ + { + "expression": "effective_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_deleted_at": { + "name": "IDX_kilo_pass_scheduled_changes_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk": { + "name": "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "stripe_subscription_id" + ], + "columnsTo": [ + "stripe_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_scheduled_changes_from_tier_check": { + "name": "kilo_pass_scheduled_changes_from_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_from_cadence_check": { + "name": "kilo_pass_scheduled_changes_from_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_to_tier_check": { + "name": "kilo_pass_scheduled_changes_to_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_to_cadence_check": { + "name": "kilo_pass_scheduled_changes_to_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_status_check": { + "name": "kilo_pass_scheduled_changes_status_check", + "value": "\"kilo_pass_scheduled_changes\".\"status\" IN ('not_started', 'active', 'completed', 'released', 'canceled')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_store_events": { + "name": "kilo_pass_store_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_transaction_id": { + "name": "provider_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_account_token": { + "name": "app_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_store_events_provider_event": { + "name": "UQ_kilo_pass_store_events_provider_event", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_events_provider_subscription": { + "name": "IDX_kilo_pass_store_events_provider_subscription", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_events_app_account_token": { + "name": "IDX_kilo_pass_store_events_app_account_token", + "columns": [ + { + "expression": "app_account_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_store_events_payment_provider_check": { + "name": "kilo_pass_store_events_payment_provider_check", + "value": "\"kilo_pass_store_events\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_store_purchases": { + "name": "kilo_pass_store_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_transaction_id": { + "name": "provider_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_original_transaction_id": { + "name": "provider_original_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_account_token": { + "name": "app_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purchase_token": { + "name": "purchase_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_payload_json": { + "name": "raw_payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_store_purchases_provider_transaction": { + "name": "UQ_kilo_pass_store_purchases_provider_transaction", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_subscription_id": { + "name": "IDX_kilo_pass_store_purchases_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_user_id": { + "name": "IDX_kilo_pass_store_purchases_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_app_account_token": { + "name": "IDX_kilo_pass_store_purchases_app_account_token", + "columns": [ + { + "expression": "app_account_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_latest_subscription_purchase": { + "name": "IDX_kilo_pass_store_purchases_latest_subscription_purchase", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchased_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_store_purchases_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_store_purchases_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_store_purchases_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_store_purchases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "FK_kilo_pass_store_purchases_subscription_owner_provider": { + "name": "FK_kilo_pass_store_purchases_subscription_owner_provider", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id", + "kilo_user_id", + "payment_provider", + "provider_subscription_id" + ], + "columnsTo": [ + "id", + "kilo_user_id", + "payment_provider", + "provider_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_store_purchases_store_provider_check": { + "name": "kilo_pass_store_purchases_store_provider_check", + "value": "\"kilo_pass_store_purchases\".\"payment_provider\" IN ('app_store', 'google_play')" + }, + "kilo_pass_store_purchases_payment_provider_check": { + "name": "kilo_pass_store_purchases_payment_provider_check", + "value": "\"kilo_pass_store_purchases\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_subscriptions": { + "name": "kilo_pass_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_streak_months": { + "name": "current_streak_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_yearly_issue_at": { + "name": "next_yearly_issue_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_subscriptions_kilo_user_id": { + "name": "IDX_kilo_pass_subscriptions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_payment_provider": { + "name": "IDX_kilo_pass_subscriptions_payment_provider", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_status": { + "name": "IDX_kilo_pass_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_cadence": { + "name": "IDX_kilo_pass_subscriptions_cadence", + "columns": [ + { + "expression": "cadence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_subscriptions_provider_subscription": { + "name": "UQ_kilo_pass_subscriptions_provider_subscription", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_subscriptions_store_purchase_reference": { + "name": "UQ_kilo_pass_subscriptions_store_purchase_reference", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_subscriptions_stripe_subscription_id_unique": { + "name": "kilo_pass_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_subscriptions_current_streak_months_non_negative_check": { + "name": "kilo_pass_subscriptions_current_streak_months_non_negative_check", + "value": "\"kilo_pass_subscriptions\".\"current_streak_months\" >= 0" + }, + "kilo_pass_subscriptions_provider_ids_check": { + "name": "kilo_pass_subscriptions_provider_ids_check", + "value": "(\n \"kilo_pass_subscriptions\".\"payment_provider\" = 'stripe'\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"stripe_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" = \"kilo_pass_subscriptions\".\"stripe_subscription_id\"\n ) OR (\n \"kilo_pass_subscriptions\".\"payment_provider\" IN ('app_store', 'google_play')\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"stripe_subscription_id\" IS NULL\n )" + }, + "kilo_pass_subscriptions_payment_provider_check": { + "name": "kilo_pass_subscriptions_payment_provider_check", + "value": "\"kilo_pass_subscriptions\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + }, + "kilo_pass_subscriptions_tier_check": { + "name": "kilo_pass_subscriptions_tier_check", + "value": "\"kilo_pass_subscriptions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_subscriptions_cadence_check": { + "name": "kilo_pass_subscriptions_cadence_check", + "value": "\"kilo_pass_subscriptions\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_welcome_promo_payment_fingerprint_claims": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims", + "schema": "", + "columns": { + "stripe_payment_method_type": { + "name": "stripe_payment_method_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_stripe_invoice_id": { + "name": "source_stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "kilo_pass_welcome_promo_payment_fingerprint_claims_stripe_payment_method_type_stripe_fingerprint_pk": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims_stripe_payment_method_type_stripe_fingerprint_pk", + "columns": [ + "stripe_payment_method_type", + "stripe_fingerprint" + ] + } + }, + "uniqueConstraints": { + "UQ_kilo_pass_welcome_promo_payment_fingerprint_claims_source_invoice_id": { + "name": "UQ_kilo_pass_welcome_promo_payment_fingerprint_claims_source_invoice_id", + "nullsNotDistinct": false, + "columns": [ + "source_stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_welcome_promo_payment_fingerprint_claims_type_check": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims_type_check", + "value": "\"kilo_pass_welcome_promo_payment_fingerprint_claims\".\"stripe_payment_method_type\" IN ('card', 'sepa_debit', 'us_bank_account', 'bacs_debit', 'au_becs_debit')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_access_codes": { + "name": "kiloclaw_access_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_access_codes_code": { + "name": "UQ_kiloclaw_access_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_access_codes_user_status": { + "name": "IDX_kiloclaw_access_codes_user_status", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_access_codes_one_active_per_user": { + "name": "UQ_kiloclaw_access_codes_one_active_per_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_access_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_admin_audit_logs": { + "name": "kiloclaw_admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_admin_audit_logs_target_user_id": { + "name": "IDX_kiloclaw_admin_audit_logs_target_user_id", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_action": { + "name": "IDX_kiloclaw_admin_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_created_at": { + "name": "IDX_kiloclaw_admin_audit_logs_created_at", + "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.kiloclaw_cli_runs": { + "name": "kiloclaw_cli_runs", + "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 + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "initiated_by_admin_id": { + "name": "initiated_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_cli_runs_user_id": { + "name": "IDX_kiloclaw_cli_runs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_started_at": { + "name": "IDX_kiloclaw_cli_runs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_instance_id": { + "name": "IDX_kiloclaw_cli_runs_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_cli_runs_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "initiated_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_earlybird_purchases": { + "name": "kiloclaw_earlybird_purchases", + "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 + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_payment_id": { + "name": "manual_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_earlybird_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_earlybird_purchases_user_id_unique": { + "name": "kiloclaw_earlybird_purchases_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "kiloclaw_earlybird_purchases_stripe_charge_id_unique": { + "name": "kiloclaw_earlybird_purchases_stripe_charge_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_charge_id" + ] + }, + "kiloclaw_earlybird_purchases_manual_payment_id_unique": { + "name": "kiloclaw_earlybird_purchases_manual_payment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manual_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_email_log": { + "name": "kiloclaw_email_log", + "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 + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'epoch'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_email_log_user_type_global": { + "name": "UQ_kiloclaw_email_log_user_type_global", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_email_log_user_instance_type_period": { + "name": "UQ_kiloclaw_email_log_user_instance_type_period", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_email_log_type_sent_instance": { + "name": "IDX_kiloclaw_email_log_type_sent_instance", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_email_log_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_google_oauth_connections": { + "name": "kiloclaw_google_oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_subject": { + "name": "account_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_profile": { + "name": "credential_profile", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kilo_owned'" + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "grants_by_source": { + "name": "grants_by_source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_google_oauth_connections_instance": { + "name": "UQ_kiloclaw_google_oauth_connections_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_status": { + "name": "IDX_kiloclaw_google_oauth_connections_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_provider": { + "name": "IDX_kiloclaw_google_oauth_connections_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_google_oauth_connections", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_google_oauth_connections_status_check": { + "name": "kiloclaw_google_oauth_connections_status_check", + "value": "\"kiloclaw_google_oauth_connections\".\"status\" IN ('active', 'action_required', 'disconnected')" + }, + "kiloclaw_google_oauth_connections_credential_profile_check": { + "name": "kiloclaw_google_oauth_connections_credential_profile_check", + "value": "\"kiloclaw_google_oauth_connections\".\"credential_profile\" IN ('legacy', 'kilo_owned')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_image_catalog": { + "name": "kiloclaw_image_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_digest": { + "name": "image_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rollout_percent": { + "name": "rollout_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_latest": { + "name": "is_latest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kiloclaw_image_catalog_status": { + "name": "IDX_kiloclaw_image_catalog_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_image_catalog_variant": { + "name": "IDX_kiloclaw_image_catalog_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_latest_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_latest_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_candidate_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_candidate_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = false AND \"kiloclaw_image_catalog\".\"rollout_percent\" > 0 AND \"kiloclaw_image_catalog\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_image_catalog_image_tag_unique": { + "name": "kiloclaw_image_catalog_image_tag_unique", + "nullsNotDistinct": false, + "columns": [ + "image_tag" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_aliases": { + "name": "kiloclaw_inbound_email_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_inbound_email_aliases_instance_id": { + "name": "IDX_kiloclaw_inbound_email_aliases_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_inbound_email_aliases_active_instance": { + "name": "UQ_kiloclaw_inbound_email_aliases_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_inbound_email_aliases\".\"retired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_inbound_email_aliases", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_reserved_aliases": { + "name": "kiloclaw_inbound_email_reserved_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_instances": { + "name": "kiloclaw_instances", + "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 + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fly'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_email_enabled": { + "name": "inbound_email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inactive_trial_stopped_at": { + "name": "inactive_trial_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracked_image_tag": { + "name": "tracked_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_size_override": { + "name": "admin_size_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_instances_active": { + "name": "UQ_kiloclaw_instances_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_personal_by_user": { + "name": "IDX_kiloclaw_instances_active_personal_by_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_user_org": { + "name": "IDX_kiloclaw_instances_active_org_by_user_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_org_created": { + "name": "IDX_kiloclaw_instances_active_org_by_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_user_id_created_at": { + "name": "IDX_kiloclaw_instances_user_id_created_at", + "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": {} + }, + "IDX_kiloclaw_instances_tracked_image_tag": { + "name": "IDX_kiloclaw_instances_tracked_image_tag", + "columns": [ + { + "expression": "tracked_image_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_instance_type": { + "name": "IDX_kiloclaw_instances_instance_type", + "columns": [ + { + "expression": "instance_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_admin_size_override": { + "name": "IDX_kiloclaw_instances_admin_size_override", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"admin_size_override\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_instances_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_instances_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_instances_organization_id_organizations_id_fk": { + "name": "kiloclaw_instances_organization_id_organizations_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_kiloclaw_instances_instance_type": { + "name": "CHK_kiloclaw_instances_instance_type", + "value": "\"kiloclaw_instances\".\"instance_type\" IS NULL OR \"kiloclaw_instances\".\"instance_type\" IN ('perf-1-3', 'perf-4-8', 'perf-4-16', 'shared-2-3', 'shared-2-4', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_morning_briefing_configs": { + "name": "kiloclaw_morning_briefing_configs", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0 7 * * *'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "interest_topics": { + "name": "interest_topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_morning_briefing_configs_enabled": { + "name": "IDX_kiloclaw_morning_briefing_configs_enabled", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_morning_briefing_configs\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_morning_briefing_configs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_morning_briefing_configs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_morning_briefing_configs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_notifications": { + "name": "kiloclaw_scheduled_action_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'notice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel": { + "name": "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_notifications_pending": { + "name": "IDX_kiloclaw_scheduled_action_notifications_pending", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk": { + "name": "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk", + "tableFrom": "kiloclaw_scheduled_action_notifications", + "tableTo": "kiloclaw_scheduled_action_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_stages": { + "name": "kiloclaw_scheduled_action_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_index": { + "name": "stage_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notice_sent_at": { + "name": "notice_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_stages_parent_index": { + "name": "UQ_kiloclaw_scheduled_action_stages_parent_index", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_stages_notice_due": { + "name": "IDX_kiloclaw_scheduled_action_stages_notice_due", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_stages\".\"notice_sent_at\" IS NULL AND \"kiloclaw_scheduled_action_stages\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_stages", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_targets": { + "name": "kiloclaw_scheduled_action_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_image_tag": { + "name": "source_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_targets_parent_instance": { + "name": "UQ_kiloclaw_scheduled_action_targets_parent_instance", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_stage": { + "name": "IDX_kiloclaw_scheduled_action_targets_stage", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_pending_by_instance": { + "name": "IDX_kiloclaw_scheduled_action_targets_pending_by_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_targets\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk": { + "name": "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_action_stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_actions": { + "name": "kiloclaw_scheduled_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_pins": { + "name": "override_pins", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notice_lead_hours": { + "name": "notice_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "notice_subject": { + "name": "notice_subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "notice_body": { + "name": "notice_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "IDX_kiloclaw_scheduled_actions_status": { + "name": "IDX_kiloclaw_scheduled_actions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_action_type": { + "name": "IDX_kiloclaw_scheduled_actions_action_type", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_created_by": { + "name": "IDX_kiloclaw_scheduled_actions_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "target_image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_subscription_change_log": { + "name": "kiloclaw_subscription_change_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_subscription_change_log_subscription_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_subscription_created_at", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscription_change_log_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscription_change_log", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscription_change_log_actor_type_check": { + "name": "kiloclaw_subscription_change_log_actor_type_check", + "value": "\"kiloclaw_subscription_change_log\".\"actor_type\" IN ('user', 'system')" + }, + "kiloclaw_subscription_change_log_action_check": { + "name": "kiloclaw_subscription_change_log_action_check", + "value": "\"kiloclaw_subscription_change_log\".\"action\" IN ('created', 'status_changed', 'plan_switched', 'period_advanced', 'canceled', 'reactivated', 'suspended', 'destruction_scheduled', 'reassigned', 'backfilled', 'payment_source_changed', 'schedule_changed', 'admin_override')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_subscriptions": { + "name": "kiloclaw_subscriptions", + "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 + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transferred_to_subscription_id": { + "name": "transferred_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_origin": { + "name": "access_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_source": { + "name": "payment_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kiloclaw_price_version": { + "name": "kiloclaw_price_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_plan": { + "name": "scheduled_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_by": { + "name": "scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_conversion": { + "name": "pending_conversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trial_started_at": { + "name": "trial_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commit_ends_at": { + "name": "commit_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "destruction_deadline": { + "name": "destruction_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_requested_at": { + "name": "auto_resume_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_retry_after": { + "name": "auto_resume_retry_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_attempt_count": { + "name": "auto_resume_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "auto_top_up_triggered_for_period": { + "name": "auto_top_up_triggered_for_period", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_subscriptions_status": { + "name": "IDX_kiloclaw_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_id": { + "name": "IDX_kiloclaw_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_status": { + "name": "IDX_kiloclaw_subscriptions_user_status", + "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": {} + }, + "IDX_kiloclaw_subscriptions_price_version": { + "name": "IDX_kiloclaw_subscriptions_price_version", + "columns": [ + { + "expression": "kiloclaw_price_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_transferred_to": { + "name": "IDX_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_stripe_schedule_id": { + "name": "IDX_kiloclaw_subscriptions_stripe_schedule_id", + "columns": [ + { + "expression": "stripe_schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_auto_resume_retry_after": { + "name": "IDX_kiloclaw_subscriptions_auto_resume_retry_after", + "columns": [ + { + "expression": "auto_resume_retry_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_instance": { + "name": "UQ_kiloclaw_subscriptions_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_transferred_to": { + "name": "UQ_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"transferred_to_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_earlybird_origin": { + "name": "IDX_kiloclaw_subscriptions_earlybird_origin", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_subscriptions\".\"access_origin\" = 'earlybird'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscriptions_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "transferred_to_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_subscriptions_stripe_subscription_id_unique": { + "name": "kiloclaw_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscriptions_price_version_check": { + "name": "kiloclaw_subscriptions_price_version_check", + "value": "\"kiloclaw_subscriptions\".\"kiloclaw_price_version\" IN ('2026-03-19', '2026-05-10')" + }, + "kiloclaw_subscriptions_plan_check": { + "name": "kiloclaw_subscriptions_plan_check", + "value": "\"kiloclaw_subscriptions\".\"plan\" IN ('trial', 'commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_plan_check": { + "name": "kiloclaw_subscriptions_scheduled_plan_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_plan\" IN ('commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_by_check": { + "name": "kiloclaw_subscriptions_scheduled_by_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_by\" IN ('auto', 'user')" + }, + "kiloclaw_subscriptions_status_check": { + "name": "kiloclaw_subscriptions_status_check", + "value": "\"kiloclaw_subscriptions\".\"status\" IN ('trialing', 'active', 'past_due', 'canceled', 'unpaid')" + }, + "kiloclaw_subscriptions_access_origin_check": { + "name": "kiloclaw_subscriptions_access_origin_check", + "value": "\"kiloclaw_subscriptions\".\"access_origin\" IN ('earlybird')" + }, + "kiloclaw_subscriptions_payment_source_check": { + "name": "kiloclaw_subscriptions_payment_source_check", + "value": "\"kiloclaw_subscriptions\".\"payment_source\" IN ('stripe', 'credits')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_terminal_renewal_failures": { + "name": "kiloclaw_terminal_renewal_failures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "renewal_boundary": { + "name": "renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unresolved'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_failure_code": { + "name": "last_failure_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_failure_message": { + "name": "last_failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_actor_type": { + "name": "resolution_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_actor_id": { + "name": "resolution_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_at": { + "name": "resolution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_reason": { + "name": "resolution_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_terminal_renewal_failures_subscription_boundary": { + "name": "UQ_kiloclaw_terminal_renewal_failures_subscription_boundary", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "renewal_boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_terminal_renewal_failures_unresolved": { + "name": "IDX_kiloclaw_terminal_renewal_failures_unresolved", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "renewal_boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_terminal_renewal_failures\".\"status\" = 'unresolved'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_terminal_renewal_failures_status_last_failure_at": { + "name": "IDX_kiloclaw_terminal_renewal_failures_status_last_failure_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_terminal_renewal_failures_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_terminal_renewal_failures_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_terminal_renewal_failures", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_terminal_renewal_failures_status_check": { + "name": "kiloclaw_terminal_renewal_failures_status_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"status\" IN ('unresolved', 'resolved', 'waived', 'superseded')" + }, + "kiloclaw_terminal_renewal_failures_last_failure_code_check": { + "name": "kiloclaw_terminal_renewal_failures_last_failure_code_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"last_failure_code\" IN ('credit_balance_read_failed', 'renewal_transaction_failed', 'auto_top_up_marker_write_failed', 'worker_timeout', 'poison_payload', 'queue_delivery_exhausted')" + }, + "kiloclaw_terminal_renewal_failures_resolution_actor_type_check": { + "name": "kiloclaw_terminal_renewal_failures_resolution_actor_type_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"resolution_actor_type\" IN ('operator', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_version_pins": { + "name": "kiloclaw_version_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_by": { + "name": "pinned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk": { + "name": "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kilocode_users", + "columnsFrom": [ + "pinned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_version_pins_instance_id_unique": { + "name": "kiloclaw_version_pins_instance_id_unique", + "nullsNotDistinct": false, + "columns": [ + "instance_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilocode_users": { + "name": "kilocode_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "google_user_email": { + "name": "google_user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_name": { + "name": "google_user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_image_url": { + "name": "google_user_image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "kilo_pass_threshold": { + "name": "kilo_pass_threshold", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_store_account_token": { + "name": "app_store_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_view_sessions": { + "name": "can_view_sessions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_manage_credits": { + "name": "can_manage_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_validation_stytch": { + "name": "has_validation_stytch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_validation_novel_card_with_hold": { + "name": "has_validation_novel_card_with_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_by_kilo_user_id": { + "name": "blocked_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_token_pepper": { + "name": "api_token_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_session_pepper": { + "name": "web_session_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kiloclaw_early_access": { + "name": "kiloclaw_early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cohorts": { + "name": "cohorts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_welcome_form": { + "name": "completed_welcome_form", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_server_membership_verified_at": { + "name": "discord_server_membership_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "openrouter_upstream_safety_identifier": { + "name": "openrouter_upstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openrouter_downstream_safety_identifier": { + "name": "openrouter_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_downstream_safety_identifier": { + "name": "vercel_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_source": { + "name": "customer_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_ip": { + "name": "signup_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_deletion_requested_at": { + "name": "account_deletion_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_account_disabled": { + "name": "personal_account_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kilocode_users_signup_ip_created_at": { + "name": "IDX_kilocode_users_signup_ip_created_at", + "columns": [ + { + "expression": "signup_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_at": { + "name": "IDX_kilocode_users_blocked_at", + "columns": [ + { + "expression": "blocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_by_kilo_user_id": { + "name": "IDX_kilocode_users_blocked_by_kilo_user_id", + "columns": [ + { + "expression": "blocked_by_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_upstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_upstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_upstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_upstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_downstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_downstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_downstream_safety_identifier\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_vercel_downstream_safety_identifier": { + "name": "UQ_kilocode_users_vercel_downstream_safety_identifier", + "columns": [ + { + "expression": "vercel_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"vercel_downstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_normalized_email": { + "name": "IDX_kilocode_users_normalized_email", + "columns": [ + { + "expression": "normalized_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_email_domain": { + "name": "IDX_kilocode_users_email_domain", + "columns": [ + { + "expression": "email_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilocode_users_app_store_account_token_unique": { + "name": "kilocode_users_app_store_account_token_unique", + "nullsNotDistinct": false, + "columns": [ + "app_store_account_token" + ] + }, + "UQ_b1afacbcf43f2c7c4cb9f7e7faa": { + "name": "UQ_b1afacbcf43f2c7c4cb9f7e7faa", + "nullsNotDistinct": false, + "columns": [ + "google_user_email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocked_reason_not_empty": { + "name": "blocked_reason_not_empty", + "value": "length(blocked_reason) > 0" + }, + "kilocode_users_is_super_admin_requires_admin_check": { + "name": "kilocode_users_is_super_admin_requires_admin_check", + "value": "NOT \"kilocode_users\".\"is_super_admin\" OR \"kilocode_users\".\"is_admin\"" + }, + "kilocode_users_can_view_sessions_requires_admin_check": { + "name": "kilocode_users_can_view_sessions_requires_admin_check", + "value": "NOT \"kilocode_users\".\"can_view_sessions\" OR \"kilocode_users\".\"is_admin\"" + }, + "kilocode_users_can_manage_credits_requires_admin_check": { + "name": "kilocode_users_can_manage_credits_requires_admin_check", + "value": "NOT \"kilocode_users\".\"can_manage_credits\" OR \"kilocode_users\".\"is_admin\"" + } + }, + "isRLSEnabled": false + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reserved_until": { + "name": "reserved_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "challenge_id": { + "name": "challenge_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_magic_link_tokens_email": { + "name": "idx_magic_link_tokens_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_magic_link_tokens_expires_at": { + "name": "idx_magic_link_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_magic_link_tokens_challenge_id": { + "name": "UQ_magic_link_tokens_challenge_id", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"magic_link_tokens\".\"challenge_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_expires_at_future": { + "name": "check_expires_at_future", + "value": "\"magic_link_tokens\".\"expires_at\" > \"magic_link_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_assignments": { + "name": "mcp_gateway_assignments", + "schema": "", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_kilo_user_id": { + "name": "assigned_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "single_user_slot": { + "name": "single_user_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_assignments_active": { + "name": "UQ_mcp_gateway_assignments_active", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_assignments\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_assignments_single_user_slot": { + "name": "UQ_mcp_gateway_assignments_single_user_slot", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "single_user_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_assignments\".\"revoked_at\" is null and \"mcp_gateway_assignments\".\"single_user_slot\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_assignments_config": { + "name": "IDX_mcp_gateway_assignments_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_assignments_user": { + "name": "IDX_mcp_gateway_assignments_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_assignments_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_assignments_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_assignments_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_assignments_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_assignments_assigned_by_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_assignments_assigned_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "assigned_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_gateway_audit_events": { + "name": "mcp_gateway_audit_events", + "schema": "", + "columns": { + "audit_event_id": { + "name": "audit_event_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "actor_kilo_user_id": { + "name": "actor_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_metadata": { + "name": "correlation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_mcp_gateway_audit_events_config": { + "name": "IDX_mcp_gateway_audit_events_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_grant": { + "name": "IDX_mcp_gateway_audit_events_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_audit_events\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_owner": { + "name": "IDX_mcp_gateway_audit_events_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_created_at": { + "name": "IDX_mcp_gateway_audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_audit_events_actor_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_audit_events_actor_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "actor_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_audit_events_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk": { + "name": "mcp_gateway_audit_events_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_connect_resources", + "columnsFrom": [ + "connect_resource_id" + ], + "columnsTo": [ + "connect_resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_audit_events_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_audit_events_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_audit_events_owner_scope": { + "name": "mcp_gateway_audit_events_owner_scope", + "value": "\"mcp_gateway_audit_events\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_audit_events_outcome": { + "name": "mcp_gateway_audit_events_outcome", + "value": "\"mcp_gateway_audit_events\".\"outcome\" IN ('success', 'failure', 'blocked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_authorization_codes": { + "name": "mcp_gateway_authorization_codes", + "schema": "", + "columns": { + "authorization_code_id": { + "name": "authorization_code_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_authorization_codes_code_hash": { + "name": "UQ_mcp_gateway_authorization_codes_code_hash", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_expires_at": { + "name": "IDX_mcp_gateway_authorization_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_client": { + "name": "IDX_mcp_gateway_authorization_codes_client", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_grant": { + "name": "IDX_mcp_gateway_authorization_codes_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_authorization_codes\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_authorization_codes_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk": { + "name": "mcp_gateway_authorization_codes_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_authorization_requests", + "columnsFrom": [ + "authorization_request_id" + ], + "columnsTo": [ + "authorization_request_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_authorization_codes_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_authorization_codes_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_authorization_codes_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_authorization_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_authorization_codes_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_authorization_codes_owner_scope": { + "name": "mcp_gateway_authorization_codes_owner_scope", + "value": "\"mcp_gateway_authorization_codes\".\"owner_scope\" IN ('personal', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_authorization_requests": { + "name": "mcp_gateway_authorization_requests", + "schema": "", + "columns": { + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "request_state_hash": { + "name": "request_state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_scopes": { + "name": "requested_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "oauth_state": { + "name": "oauth_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_status": { + "name": "request_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_authorization_requests_state_hash": { + "name": "UQ_mcp_gateway_authorization_requests_state_hash", + "columns": [ + { + "expression": "request_state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_config": { + "name": "IDX_mcp_gateway_authorization_requests_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_grant": { + "name": "IDX_mcp_gateway_authorization_requests_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_authorization_requests\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_user": { + "name": "IDX_mcp_gateway_authorization_requests_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_expires_at": { + "name": "IDX_mcp_gateway_authorization_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_authorization_requests_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_authorization_requests_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_authorization_requests_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_authorization_requests_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_authorization_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_authorization_requests_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_authorization_requests_owner_scope": { + "name": "mcp_gateway_authorization_requests_owner_scope", + "value": "\"mcp_gateway_authorization_requests\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_authorization_requests_status": { + "name": "mcp_gateway_authorization_requests_status", + "value": "\"mcp_gateway_authorization_requests\".\"request_status\" IN ('pending', 'completed', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_config_secrets": { + "name": "mcp_gateway_config_secrets", + "schema": "", + "columns": { + "config_secret_id": { + "name": "config_secret_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_kind": { + "name": "secret_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_config_secrets_active_kind": { + "name": "UQ_mcp_gateway_config_secrets_active_kind", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_config_secrets\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_config_secrets_config": { + "name": "IDX_mcp_gateway_config_secrets_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_config_secrets_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_config_secrets_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_config_secrets", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_config_secrets_version_positive": { + "name": "mcp_gateway_config_secrets_version_positive", + "value": "\"mcp_gateway_config_secrets\".\"secret_version\" > 0" + }, + "mcp_gateway_config_secrets_kind": { + "name": "mcp_gateway_config_secrets_kind", + "value": "\"mcp_gateway_config_secrets\".\"secret_kind\" IN ('static_provider_credentials', 'dynamic_registration', 'static_headers')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_configs": { + "name": "mcp_gateway_configs", + "schema": "", + "columns": { + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_mode": { + "name": "auth_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharing_mode": { + "name": "sharing_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_scopes": { + "name": "provider_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_scope_source": { + "name": "provider_scope_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "provider_resource": { + "name": "provider_resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "path_passthrough": { + "name": "path_passthrough", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "discovered_provider_metadata": { + "name": "discovered_provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "registry_metadata": { + "name": "registry_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "auxiliary_headers": { + "name": "auxiliary_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_mcp_gateway_configs_owner": { + "name": "IDX_mcp_gateway_configs_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_configs_enabled": { + "name": "IDX_mcp_gateway_configs_enabled", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_configs_remote_url": { + "name": "IDX_mcp_gateway_configs_remote_url", + "columns": [ + { + "expression": "remote_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_configs_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_configs_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_configs_name_not_empty": { + "name": "mcp_gateway_configs_name_not_empty", + "value": "length(trim(\"mcp_gateway_configs\".\"name\")) > 0" + }, + "mcp_gateway_configs_config_version_positive": { + "name": "mcp_gateway_configs_config_version_positive", + "value": "\"mcp_gateway_configs\".\"config_version\" > 0" + }, + "mcp_gateway_configs_personal_single_user": { + "name": "mcp_gateway_configs_personal_single_user", + "value": "\"mcp_gateway_configs\".\"owner_scope\" <> 'personal' OR \"mcp_gateway_configs\".\"sharing_mode\" = 'single_user'" + }, + "mcp_gateway_configs_owner_scope": { + "name": "mcp_gateway_configs_owner_scope", + "value": "\"mcp_gateway_configs\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_configs_auth_mode": { + "name": "mcp_gateway_configs_auth_mode", + "value": "\"mcp_gateway_configs\".\"auth_mode\" IN ('none', 'static_headers', 'oauth_dynamic', 'oauth_static')" + }, + "mcp_gateway_configs_sharing_mode": { + "name": "mcp_gateway_configs_sharing_mode", + "value": "\"mcp_gateway_configs\".\"sharing_mode\" IN ('single_user', 'multi_user')" + }, + "mcp_gateway_configs_provider_scope_source": { + "name": "mcp_gateway_configs_provider_scope_source", + "value": "\"mcp_gateway_configs\".\"provider_scope_source\" IN ('none', 'discovered', 'override')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_connect_resources": { + "name": "mcp_gateway_connect_resources", + "schema": "", + "columns": { + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_status": { + "name": "route_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "route_version": { + "name": "route_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_connect_resources_route_key": { + "name": "UQ_mcp_gateway_connect_resources_route_key", + "columns": [ + { + "expression": "route_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_connect_resources_active_config": { + "name": "UQ_mcp_gateway_connect_resources_active_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_connect_resources\".\"route_status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connect_resources_config": { + "name": "IDX_mcp_gateway_connect_resources_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connect_resources_canonical_url": { + "name": "IDX_mcp_gateway_connect_resources_canonical_url", + "columns": [ + { + "expression": "canonical_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_connect_resources_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_connect_resources_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_connect_resources", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_connect_resources_route_key_format": { + "name": "mcp_gateway_connect_resources_route_key_format", + "value": "\"mcp_gateway_connect_resources\".\"route_key\" ~ '^[A-Za-z0-9_-]{32,}$'" + }, + "mcp_gateway_connect_resources_route_version_positive": { + "name": "mcp_gateway_connect_resources_route_version_positive", + "value": "\"mcp_gateway_connect_resources\".\"route_version\" > 0" + }, + "mcp_gateway_connect_resources_owner_scope": { + "name": "mcp_gateway_connect_resources_owner_scope", + "value": "\"mcp_gateway_connect_resources\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_connect_resources_route_status": { + "name": "mcp_gateway_connect_resources_route_status", + "value": "\"mcp_gateway_connect_resources\".\"route_status\" IN ('active', 'rotated', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_connection_instances": { + "name": "mcp_gateway_connection_instances", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_status": { + "name": "instance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "instance_version": { + "name": "instance_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_connection_instances_non_terminal": { + "name": "UQ_mcp_gateway_connection_instances_non_terminal", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_connection_instances\".\"instance_status\" IN ('active', 'needs_reauth')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connection_instances_config": { + "name": "IDX_mcp_gateway_connection_instances_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connection_instances_user": { + "name": "IDX_mcp_gateway_connection_instances_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_connection_instances_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_connection_instances_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_connection_instances", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_connection_instances_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_connection_instances_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_connection_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_connection_instances_version_positive": { + "name": "mcp_gateway_connection_instances_version_positive", + "value": "\"mcp_gateway_connection_instances\".\"instance_version\" > 0" + }, + "mcp_gateway_connection_instances_owner_scope": { + "name": "mcp_gateway_connection_instances_owner_scope", + "value": "\"mcp_gateway_connection_instances\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_connection_instances_status": { + "name": "mcp_gateway_connection_instances_status", + "value": "\"mcp_gateway_connection_instances\".\"instance_status\" IN ('active', 'needs_reauth', 'revoked', 'removed')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_oauth_clients": { + "name": "mcp_gateway_oauth_clients", + "schema": "", + "columns": { + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_token_hash": { + "name": "registration_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "declared_scopes": { + "name": "declared_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "registration_access_token_expires_at": { + "name": "registration_access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_oauth_clients_client_id": { + "name": "UQ_mcp_gateway_oauth_clients_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_oauth_clients_registration_token_hash": { + "name": "UQ_mcp_gateway_oauth_clients_registration_token_hash", + "columns": [ + { + "expression": "registration_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_clients_deleted_at": { + "name": "IDX_mcp_gateway_oauth_clients_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_oauth_clients_client_id_format": { + "name": "mcp_gateway_oauth_clients_client_id_format", + "value": "\"mcp_gateway_oauth_clients\".\"client_id\" ~ '^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$'" + }, + "mcp_gateway_oauth_clients_auth_method": { + "name": "mcp_gateway_oauth_clients_auth_method", + "value": "\"mcp_gateway_oauth_clients\".\"token_endpoint_auth_method\" IN ('none', 'client_secret_post', 'client_secret_basic')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_oauth_grants": { + "name": "mcp_gateway_oauth_grants", + "schema": "", + "columns": { + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_status": { + "name": "grant_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_oauth_grants_active_binding": { + "name": "UQ_mcp_gateway_oauth_grants_active_binding", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connect_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "redirect_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_oauth_grants\".\"revoked_at\" is null and \"mcp_gateway_oauth_grants\".\"grant_status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_client": { + "name": "IDX_mcp_gateway_oauth_grants_client", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_user": { + "name": "IDX_mcp_gateway_oauth_grants_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_config": { + "name": "IDX_mcp_gateway_oauth_grants_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_owner": { + "name": "IDX_mcp_gateway_oauth_grants_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_resource": { + "name": "IDX_mcp_gateway_oauth_grants_resource", + "columns": [ + { + "expression": "connect_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_instance": { + "name": "IDX_mcp_gateway_oauth_grants_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_revoked_at": { + "name": "IDX_mcp_gateway_oauth_grants_revoked_at", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_oauth_grants_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_oauth_grants_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_oauth_grants_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_oauth_grants_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk": { + "name": "mcp_gateway_oauth_grants_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_connect_resources", + "columnsFrom": [ + "connect_resource_id" + ], + "columnsTo": [ + "connect_resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_oauth_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_oauth_grants_config_version_positive": { + "name": "mcp_gateway_oauth_grants_config_version_positive", + "value": "\"mcp_gateway_oauth_grants\".\"config_version\" > 0" + }, + "mcp_gateway_oauth_grants_owner_scope": { + "name": "mcp_gateway_oauth_grants_owner_scope", + "value": "\"mcp_gateway_oauth_grants\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_oauth_grants_status": { + "name": "mcp_gateway_oauth_grants_status", + "value": "\"mcp_gateway_oauth_grants\".\"grant_status\" IN ('pending', 'active', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_pending_provider_authorizations": { + "name": "mcp_gateway_pending_provider_authorizations", + "schema": "", + "columns": { + "pending_provider_authorization_id": { + "name": "pending_provider_authorization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_mode": { + "name": "auth_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_authorization_endpoint": { + "name": "provider_authorization_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_token_endpoint": { + "name": "provider_token_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pending_status": { + "name": "pending_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_pending_provider_authorizations_state_hash": { + "name": "UQ_mcp_gateway_pending_provider_authorizations_state_hash", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_config": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_grant": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_pending_provider_authorizations\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_expires_at": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_pending_provider_authorizations_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_authorization_requests", + "columnsFrom": [ + "authorization_request_id" + ], + "columnsTo": [ + "authorization_request_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_pending_provider_authorizations_config_version_positive": { + "name": "mcp_gateway_pending_provider_authorizations_config_version_positive", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"config_version\" > 0" + }, + "mcp_gateway_pending_provider_authorizations_owner_scope": { + "name": "mcp_gateway_pending_provider_authorizations_owner_scope", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_pending_provider_authorizations_auth_mode": { + "name": "mcp_gateway_pending_provider_authorizations_auth_mode", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"auth_mode\" IN ('none', 'static_headers', 'oauth_dynamic', 'oauth_static')" + }, + "mcp_gateway_pending_provider_authorizations_status": { + "name": "mcp_gateway_pending_provider_authorizations_status", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"pending_status\" IN ('pending', 'completed', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_provider_grants": { + "name": "mcp_gateway_provider_grants", + "schema": "", + "columns": { + "provider_grant_id": { + "name": "provider_grant_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_grant": { + "name": "encrypted_grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subject": { + "name": "provider_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_scope": { + "name": "grant_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grant_status": { + "name": "grant_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "grant_version": { + "name": "grant_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_provider_grants_active_instance": { + "name": "UQ_mcp_gateway_provider_grants_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_provider_grants\".\"grant_status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_provider_grants_instance": { + "name": "IDX_mcp_gateway_provider_grants_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_provider_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_provider_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_provider_grants", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_provider_grants_version_positive": { + "name": "mcp_gateway_provider_grants_version_positive", + "value": "\"mcp_gateway_provider_grants\".\"grant_version\" > 0" + }, + "mcp_gateway_provider_grants_status": { + "name": "mcp_gateway_provider_grants_status", + "value": "\"mcp_gateway_provider_grants\".\"grant_status\" IN ('active', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_rate_limit_windows": { + "name": "mcp_gateway_rate_limit_windows", + "schema": "", + "columns": { + "rate_limit_window_id": { + "name": "rate_limit_window_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_rate_limit_windows_ip_window": { + "name": "UQ_mcp_gateway_rate_limit_windows_ip_window", + "columns": [ + { + "expression": "ip_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_rate_limit_windows_window": { + "name": "IDX_mcp_gateway_rate_limit_windows_window", + "columns": [ + { + "expression": "window_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_rate_limit_windows_attempt_count_non_negative": { + "name": "mcp_gateway_rate_limit_windows_attempt_count_non_negative", + "value": "\"mcp_gateway_rate_limit_windows\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_refresh_tokens": { + "name": "mcp_gateway_refresh_tokens", + "schema": "", + "columns": { + "refresh_token_id": { + "name": "refresh_token_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotated_from_refresh_token_id": { + "name": "rotated_from_refresh_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_refresh_tokens_token_hash": { + "name": "UQ_mcp_gateway_refresh_tokens_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_user": { + "name": "IDX_mcp_gateway_refresh_tokens_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_grant": { + "name": "IDX_mcp_gateway_refresh_tokens_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_refresh_tokens\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_config": { + "name": "IDX_mcp_gateway_refresh_tokens_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_consumed_at": { + "name": "IDX_mcp_gateway_refresh_tokens_consumed_at", + "columns": [ + { + "expression": "consumed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_refresh_tokens_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_refresh_tokens_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_refresh_tokens_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_refresh_tokens_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_refresh_tokens_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_refresh_tokens_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_refresh_tokens_owner_scope": { + "name": "mcp_gateway_refresh_tokens_owner_scope", + "value": "\"mcp_gateway_refresh_tokens\".\"owner_scope\" IN ('personal', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage": { + "name": "microdollar_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_created_at": { + "name": "idx_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_abuse_classification": { + "name": "idx_abuse_classification", + "columns": [ + { + "expression": "abuse_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id_created_at2": { + "name": "idx_kilo_user_id_created_at2", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_organization_id": { + "name": "idx_microdollar_usage_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_daily": { + "name": "microdollar_usage_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_microdollar_usage_daily_personal": { + "name": "idx_microdollar_usage_daily_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"microdollar_usage_daily\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_daily_org": { + "name": "idx_microdollar_usage_daily_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"microdollar_usage_daily\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_daily_repairs": { + "name": "microdollar_usage_daily_repairs", + "schema": "", + "columns": { + "usage_id": { + "name": "usage_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_microdollar_usage_daily_repairs_claim": { + "name": "IDX_microdollar_usage_daily_repairs_claim", + "columns": [ + { + "expression": "attempt_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_daily_repairs_usage_id_microdollar_usage_id_fk": { + "name": "microdollar_usage_daily_repairs_usage_id_microdollar_usage_id_fk", + "tableFrom": "microdollar_usage_daily_repairs", + "tableTo": "microdollar_usage", + "columnsFrom": [ + "usage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "microdollar_usage_daily_repairs_attempt_count_check": { + "name": "microdollar_usage_daily_repairs_attempt_count_check", + "value": "\"microdollar_usage_daily_repairs\".\"attempt_count\" >= 0" + }, + "microdollar_usage_daily_repairs_claim_token_check": { + "name": "microdollar_usage_daily_repairs_claim_token_check", + "value": "(\"microdollar_usage_daily_repairs\".\"claimed_at\" IS NULL AND \"microdollar_usage_daily_repairs\".\"claim_token\" IS NULL) OR (\"microdollar_usage_daily_repairs\".\"claimed_at\" IS NOT NULL AND \"microdollar_usage_daily_repairs\".\"claim_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage_metadata": { + "name": "microdollar_usage_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_ip_id": { + "name": "http_ip_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_latitude": { + "name": "vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_longitude": { + "name": "vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason_id": { + "name": "finish_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name_id": { + "name": "editor_name_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_kind_id": { + "name": "api_kind_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_model_id": { + "name": "auto_model_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "abuse_delay": { + "name": "abuse_delay", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abuse_downgraded_from": { + "name": "abuse_downgraded_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_microdollar_usage_metadata_created_at": { + "name": "idx_microdollar_usage_metadata_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_metadata_session_id": { + "name": "idx_microdollar_usage_metadata_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage_metadata\".\"session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk": { + "name": "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_user_agent", + "columnsFrom": [ + "http_user_agent_id" + ], + "columnsTo": [ + "http_user_agent_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk": { + "name": "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_ip", + "columnsFrom": [ + "http_ip_id" + ], + "columnsTo": [ + "http_ip_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_city", + "columnsFrom": [ + "vercel_ip_city_id" + ], + "columnsTo": [ + "vercel_ip_city_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_country", + "columnsFrom": [ + "vercel_ip_country_id" + ], + "columnsTo": [ + "vercel_ip_country_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk": { + "name": "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "ja4_digest", + "columnsFrom": [ + "ja4_digest_id" + ], + "columnsTo": [ + "ja4_digest_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk": { + "name": "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "system_prompt_prefix", + "columnsFrom": [ + "system_prompt_prefix_id" + ], + "columnsTo": [ + "system_prompt_prefix_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mode": { + "name": "mode", + "schema": "", + "columns": { + "mode_id": { + "name": "mode_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_mode": { + "name": "UQ_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_stats": { + "name": "model_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_stealth": { + "name": "is_stealth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recommended": { + "name": "is_recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "openrouter_id": { + "name": "openrouter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aa_slug": { + "name": "aa_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_creator": { + "name": "model_creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_slug": { + "name": "creator_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "price_input": { + "name": "price_input", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "price_output": { + "name": "price_output", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "coding_index": { + "name": "coding_index", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "speed_tokens_per_sec": { + "name": "speed_tokens_per_sec", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "openrouter_data": { + "name": "openrouter_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "benchmarks": { + "name": "benchmarks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chart_data": { + "name": "chart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_stats_openrouter_id": { + "name": "IDX_model_stats_openrouter_id", + "columns": [ + { + "expression": "openrouter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_slug": { + "name": "IDX_model_stats_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_is_active": { + "name": "IDX_model_stats_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_creator_slug": { + "name": "IDX_model_stats_creator_slug", + "columns": [ + { + "expression": "creator_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_price_input": { + "name": "IDX_model_stats_price_input", + "columns": [ + { + "expression": "price_input", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_coding_index": { + "name": "IDX_model_stats_coding_index", + "columns": [ + { + "expression": "coding_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_context_length": { + "name": "IDX_model_stats_context_length", + "columns": [ + { + "expression": "context_length", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_stats_openrouter_id_unique": { + "name": "model_stats_openrouter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "openrouter_id" + ] + }, + "model_stats_slug_unique": { + "name": "model_stats_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_eval_ingestions": { + "name": "model_eval_ingestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bench_eval_name": { + "name": "bench_eval_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bench_eval_url": { + "name": "bench_eval_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_stats_id": { + "name": "model_stats_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_source": { + "name": "task_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n_total_trials": { + "name": "n_total_trials", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "n_attempts": { + "name": "n_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_score": { + "name": "total_score", + "type": "numeric(14, 6)", + "primaryKey": false, + "notNull": true + }, + "overall_score": { + "name": "overall_score", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": true + }, + "n_errored": { + "name": "n_errored", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avg_cost_microdollars": { + "name": "avg_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_input_tokens": { + "name": "avg_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_output_tokens": { + "name": "avg_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_cache_read_tokens": { + "name": "avg_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cache_read_tokens": { + "name": "total_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_execution_ms": { + "name": "avg_execution_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "promoted_by_email": { + "name": "promoted_by_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "promotion_note": { + "name": "promotion_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_eval_ingestions_lookup": { + "name": "IDX_model_eval_ingestions_lookup", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "promoted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_eval_ingestions_model_stats": { + "name": "IDX_model_eval_ingestions_model_stats", + "columns": [ + { + "expression": "model_stats_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_eval_ingestions_promoted_by_email_lower": { + "name": "IDX_model_eval_ingestions_promoted_by_email_lower", + "columns": [ + { + "expression": "LOWER(\"promoted_by_email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_eval_ingestions_model_stats_id_model_stats_id_fk": { + "name": "model_eval_ingestions_model_stats_id_model_stats_id_fk", + "tableFrom": "model_eval_ingestions", + "tableTo": "model_stats", + "columnsFrom": [ + "model_stats_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_eval_ingestions_bench_eval_name_unique": { + "name": "model_eval_ingestions_bench_eval_name_unique", + "nullsNotDistinct": false, + "columns": [ + "bench_eval_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_experiment": { + "name": "model_experiment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "public_model_id": { + "name": "public_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_model_experiment_public_model_id_routing": { + "name": "UQ_model_experiment_public_model_id_routing", + "columns": [ + { + "expression": "public_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"model_experiment\".\"status\" IN ('active', 'paused')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_experiment_status": { + "name": "IDX_model_experiment_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_created_by_user_id_kilocode_users_id_fk": { + "name": "model_experiment_created_by_user_id_kilocode_users_id_fk", + "tableFrom": "model_experiment", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_experiment_status_valid": { + "name": "model_experiment_status_valid", + "value": "\"model_experiment\".\"status\" IN ('draft', 'active', 'paused', 'completed')" + }, + "model_experiment_active_not_archived": { + "name": "model_experiment_active_not_archived", + "value": "\"model_experiment\".\"status\" <> 'active' OR \"model_experiment\".\"is_archived\" = false" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_request": { + "name": "model_experiment_request", + "schema": "", + "columns": { + "usage_id": { + "name": "usage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "variant_version_id": { + "name": "variant_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_subject": { + "name": "allocation_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_kind": { + "name": "request_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_sha256": { + "name": "request_body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "was_truncated": { + "name": "was_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_request_variant_version_created_at": { + "name": "IDX_model_experiment_request_variant_version_created_at", + "columns": [ + { + "expression": "variant_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_experiment_request_client_request_id": { + "name": "IDX_model_experiment_request_client_request_id", + "columns": [ + { + "expression": "client_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"model_experiment_request\".\"client_request_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_request_usage_id_microdollar_usage_id_fk": { + "name": "model_experiment_request_usage_id_microdollar_usage_id_fk", + "tableFrom": "model_experiment_request", + "tableTo": "microdollar_usage", + "columnsFrom": [ + "usage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_experiment_request_variant_version_id_model_experiment_variant_version_id_fk": { + "name": "model_experiment_request_variant_version_id_model_experiment_variant_version_id_fk", + "tableFrom": "model_experiment_request", + "tableTo": "model_experiment_variant_version", + "columnsFrom": [ + "variant_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_experiment_request_usage_id_created_at_pk": { + "name": "model_experiment_request_usage_id_created_at_pk", + "columns": [ + "usage_id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_experiment_request_allocation_subject_valid": { + "name": "model_experiment_request_allocation_subject_valid", + "value": "\"model_experiment_request\".\"allocation_subject\" IN ('user', 'machine', 'ip')" + }, + "model_experiment_request_request_kind_valid": { + "name": "model_experiment_request_request_kind_valid", + "value": "\"model_experiment_request\".\"request_kind\" IN ('chat_completions', 'messages', 'responses')" + }, + "model_experiment_request_request_body_sha256_format": { + "name": "model_experiment_request_request_body_sha256_format", + "value": "\"model_experiment_request\".\"request_body_sha256\" ~ '^[0-9a-f]{64}$' OR \"model_experiment_request\".\"request_body_sha256\" IN ('__failed__', '__deleted__')" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_variant": { + "name": "model_experiment_variant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "experiment_id": { + "name": "experiment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_variant_experiment_id": { + "name": "IDX_model_experiment_variant_experiment_id", + "columns": [ + { + "expression": "experiment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_variant_experiment_id_model_experiment_id_fk": { + "name": "model_experiment_variant_experiment_id_model_experiment_id_fk", + "tableFrom": "model_experiment_variant", + "tableTo": "model_experiment", + "columnsFrom": [ + "experiment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_model_experiment_variant_experiment_label": { + "name": "UQ_model_experiment_variant_experiment_label", + "nullsNotDistinct": false, + "columns": [ + "experiment_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": { + "model_experiment_variant_weight_positive": { + "name": "model_experiment_variant_weight_positive", + "value": "\"model_experiment_variant\".\"weight\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_variant_version": { + "name": "model_experiment_variant_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "variant_id": { + "name": "variant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upstream": { + "name": "upstream", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_variant_version_variant_effective": { + "name": "IDX_model_experiment_variant_version_variant_effective", + "columns": [ + { + "expression": "variant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_variant_version_variant_id_model_experiment_variant_id_fk": { + "name": "model_experiment_variant_version_variant_id_model_experiment_variant_id_fk", + "tableFrom": "model_experiment_variant_version", + "tableTo": "model_experiment_variant", + "columnsFrom": [ + "variant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_experiment_variant_version_created_by_kilocode_users_id_fk": { + "name": "model_experiment_variant_version_created_by_kilocode_users_id_fk", + "tableFrom": "model_experiment_variant_version", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models_by_provider": { + "name": "models_by_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openrouter": { + "name": "openrouter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "vercel": { + "name": "vercel", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.native_admission_challenges": { + "name": "native_admission_challenges", + "schema": "", + "columns": { + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_native_admission_challenges_expires_at": { + "name": "IDX_native_admission_challenges_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.native_attested_keys": { + "name": "native_attested_keys", + "schema": "", + "columns": { + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sign_count": { + "name": "sign_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attested_at": { + "name": "attested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_native_attested_keys_kilo_user_id": { + "name": "IDX_native_attested_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_attested_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "native_attested_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "native_attested_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_attested_keys_platform_check": { + "name": "native_attested_keys_platform_check", + "value": "\"native_attested_keys\".\"platform\" IN ('ios', 'android')" + } + }, + "isRLSEnabled": false + }, + "public.operation_ledgers": { + "name": "operation_ledgers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "operation_key": { + "name": "operation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "taxonomy": { + "name": "taxonomy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admitted'" + }, + "outcome_code": { + "name": "outcome_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_result": { + "name": "canonical_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_operation_ledgers_kilo_user_id_domain_operation_key": { + "name": "UQ_operation_ledgers_kilo_user_id_domain_operation_key", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_operation_ledgers_status_expires_at": { + "name": "IDX_operation_ledgers_status_expires_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_operation_ledgers_provider_ref": { + "name": "IDX_operation_ledgers_provider_ref", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_audit_logs": { + "name": "organization_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_audit_logs_organization_id": { + "name": "IDX_organization_audit_logs_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_action": { + "name": "IDX_organization_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_actor_id": { + "name": "IDX_organization_audit_logs_actor_id", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_created_at": { + "name": "IDX_organization_audit_logs_created_at", + "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.organization_group_memberships": { + "name": "organization_group_memberships", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_kilo_user_id": { + "name": "assigned_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_group_memberships_organization_user": { + "name": "IDX_organization_group_memberships_organization_user", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_organization_group_memberships_group": { + "name": "FK_organization_group_memberships_group", + "tableFrom": "organization_group_memberships", + "tableTo": "organization_groups", + "columnsFrom": [ + "organization_id", + "group_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_organization_group_memberships_member": { + "name": "FK_organization_group_memberships_member", + "tableFrom": "organization_group_memberships", + "tableTo": "organization_memberships", + "columnsFrom": [ + "organization_id", + "kilo_user_id" + ], + "columnsTo": [ + "organization_id", + "kilo_user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "PK_organization_group_memberships": { + "name": "PK_organization_group_memberships", + "columns": [ + "organization_id", + "group_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_group_policy_settings": { + "name": "organization_group_policy_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "default_policies": { + "name": "default_policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[{\"type\":\"model_access\",\"data\":{\"mode\":\"all\"}}]'::jsonb" + }, + "policy_revision": { + "name": "policy_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_by_kilo_user_id": { + "name": "updated_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_group_policy_settings_organization_id_organizations_id_fk": { + "name": "organization_group_policy_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_group_policy_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_group_policy_settings_revision_check": { + "name": "organization_group_policy_settings_revision_check", + "value": "\"organization_group_policy_settings\".\"policy_revision\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.organization_groups": { + "name": "organization_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_groups_organization_id_canonical_name": { + "name": "UQ_organization_groups_organization_id_canonical_name", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(btrim(\"name\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_groups_organization_id": { + "name": "IDX_organization_groups_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_groups_organization_id_organizations_id_fk": { + "name": "organization_groups_organization_id_organizations_id_fk", + "tableFrom": "organization_groups", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_groups_organization_id_id": { + "name": "UQ_organization_groups_organization_id_id", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_groups_name_check": { + "name": "organization_groups_name_check", + "value": "char_length(btrim(\"organization_groups\".\"name\")) BETWEEN 1 AND 80" + }, + "organization_groups_description_check": { + "name": "organization_groups_description_check", + "value": "\"organization_groups\".\"description\" IS NULL OR char_length(\"organization_groups\".\"description\") <= 500" + } + }, + "isRLSEnabled": false + }, + "public.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authentication_requirement": { + "name": "authentication_requirement", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "sso_source_organization_id": { + "name": "sso_source_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_invitations_token": { + "name": "UQ_organization_invitations_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_org_id": { + "name": "IDX_organization_invitations_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_email": { + "name": "IDX_organization_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_expires_at": { + "name": "IDX_organization_invitations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invitations_sso_source_organization_id_organizations_id_fk": { + "name": "organization_invitations_sso_source_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "sso_source_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_membership_removals": { + "name": "organization_membership_removals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_by": { + "name": "removed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_org_membership_removals_org_id": { + "name": "IDX_org_membership_removals_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_org_membership_removals_user_id": { + "name": "IDX_org_membership_removals_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_membership_removals_org_user": { + "name": "UQ_org_membership_removals_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_memberships": { + "name": "organization_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_memberships_org_id": { + "name": "IDX_organization_memberships_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_memberships_user_id": { + "name": "IDX_organization_memberships_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_memberships_org_user": { + "name": "UQ_organization_memberships_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_recommendation_dismissals": { + "name": "organization_recommendation_dismissals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_by_user_id": { + "name": "dismissed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_recommendation_dismissals_owned_by_organization_id_organizations_id_fk": { + "name": "organization_recommendation_dismissals_owned_by_organization_id_organizations_id_fk", + "tableFrom": "organization_recommendation_dismissals", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_recommendation_dismissals_dismissed_by_user_id_kilocode_users_id_fk": { + "name": "organization_recommendation_dismissals_dismissed_by_user_id_kilocode_users_id_fk", + "tableFrom": "organization_recommendation_dismissals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "dismissed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_recommendation_dismissals_org_key": { + "name": "UQ_org_recommendation_dismissals_org_key", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "recommendation_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_seats_purchases": { + "name": "organization_seats_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_stripe_id": { + "name": "subscription_stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + } + }, + "indexes": { + "IDX_organization_seats_org_id": { + "name": "IDX_organization_seats_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_expires_at": { + "name": "IDX_organization_seats_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_created_at": { + "name": "IDX_organization_seats_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_updated_at": { + "name": "IDX_organization_seats_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_starts_at": { + "name": "IDX_organization_seats_starts_at", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_seats_idempotency_key": { + "name": "UQ_organization_seats_idempotency_key", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_limits": { + "name": "organization_user_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_limit": { + "name": "microdollar_limit", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_limits_org_id": { + "name": "IDX_organization_user_limits_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_limits_user_id": { + "name": "IDX_organization_user_limits_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_limits_org_user": { + "name": "UQ_organization_user_limits_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_usage": { + "name": "organization_user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_usage": { + "name": "microdollar_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_daily_usage_org_id": { + "name": "IDX_organization_user_daily_usage_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_daily_usage_user_id": { + "name": "IDX_organization_user_daily_usage_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_daily_usage_org_user_date": { + "name": "UQ_organization_user_daily_usage_org_user_date", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type", + "usage_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "microdollars_balance": { + "name": "microdollars_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_seats": { + "name": "require_seats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sso_domain": { + "name": "sso_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_organization_id": { + "name": "parent_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'teams'" + }, + "free_trial_end_at": { + "name": "free_trial_end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_domain": { + "name": "company_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_organizations_sso_domain": { + "name": "IDX_organizations_sso_domain", + "columns": [ + { + "expression": "sso_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organizations_parent_organization_id": { + "name": "IDX_organizations_parent_organization_id", + "columns": [ + { + "expression": "parent_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_parent_organization_id_organizations_id_fk": { + "name": "organizations_parent_organization_id_organizations_id_fk", + "tableFrom": "organizations", + "tableTo": "organizations", + "columnsFrom": [ + "parent_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + }, + "organizations_not_parented_by_self_check": { + "name": "organizations_not_parented_by_self_check", + "value": "\"organizations\".\"parent_organization_id\" IS NULL OR \"organizations\".\"parent_organization_id\" <> \"organizations\".\"id\"" + } + }, + "isRLSEnabled": false + }, + "public.organization_modes": { + "name": "organization_modes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_organization_modes_organization_id": { + "name": "IDX_organization_modes_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_modes_org_id_slug": { + "name": "UQ_organization_modes_org_id_slug", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_country": { + "name": "address_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "three_d_secure_supported": { + "name": "three_d_secure_supported", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regulated_status": { + "name": "regulated_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1_check_status": { + "name": "address_line1_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_check_status": { + "name": "postal_code_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligible_for_free_credits": { + "name": "eligible_for_free_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_data": { + "name": "stripe_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_d7d7fb15569674aaadcfbc0428": { + "name": "IDX_d7d7fb15569674aaadcfbc0428", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_e1feb919d0ab8a36381d5d5138": { + "name": "IDX_e1feb919d0ab8a36381d5d5138", + "columns": [ + { + "expression": "stripe_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_payment_methods_organization_id": { + "name": "IDX_payment_methods_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_29df1b0403df5792c96bbbfdbe6": { + "name": "UQ_29df1b0403df5792c96bbbfdbe6", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_impact_sale_reversals": { + "name": "pending_impact_sale_reversals", + "schema": "", + "columns": { + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "dispute_id": { + "name": "dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_impact_sale_reversals_attempt_count_non_negative_check": { + "name": "pending_impact_sale_reversals_attempt_count_non_negative_check", + "value": "\"pending_impact_sale_reversals\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.platform_access_token_credentials": { + "name": "platform_access_token_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_credential_type": { + "name": "provider_credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_resource_id": { + "name": "provider_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_base_url": { + "name": "provider_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider_scopes": { + "name": "provider_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_verified_at": { + "name": "provider_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_access_token_credentials_integration_level": { + "name": "UQ_platform_access_token_credentials_integration_level", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_access_token_credentials\".\"provider_resource_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_access_token_credentials_resource": { + "name": "UQ_platform_access_token_credentials_resource", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_access_token_credentials\".\"provider_resource_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_access_token_credentials_authorized_by_user_id": { + "name": "IDX_platform_access_token_credentials_authorized_by_user_id", + "columns": [ + { + "expression": "authorized_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_access_token_credentials_authorized_by_user_id_kilocode_users_id_fk": { + "name": "platform_access_token_credentials_authorized_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_access_token_credentials", + "tableTo": "kilocode_users", + "columnsFrom": [ + "authorized_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_platform_access_token_credentials_parent": { + "name": "FK_platform_access_token_credentials_parent", + "tableFrom": "platform_access_token_credentials", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_access_token_credentials_credential_version_check": { + "name": "platform_access_token_credentials_credential_version_check", + "value": "\"platform_access_token_credentials\".\"credential_version\" > 0" + }, + "platform_access_token_credentials_resource_id_check": { + "name": "platform_access_token_credentials_resource_id_check", + "value": "\"platform_access_token_credentials\".\"provider_resource_id\" IS NULL OR \"platform_access_token_credentials\".\"provider_resource_id\" <> ''" + } + }, + "isRLSEnabled": false + }, + "public.platform_integrations": { + "name": "platform_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_installation_id": { + "name": "platform_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_id": { + "name": "platform_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_login": { + "name": "platform_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "repository_access": { + "name": "repository_access", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repositories_synced_at": { + "name": "repositories_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_invalid_at": { + "name": "auth_invalid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_invalid_reason": { + "name": "auth_invalid_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kilo_requester_user_id": { + "name": "kilo_requester_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_requester_account_id": { + "name": "platform_requester_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_status": { + "name": "integration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'standard'" + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_integrations_owned_by_org_platform_inst": { + "name": "UQ_platform_integrations_owned_by_org_platform_inst", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_owned_by_user_platform_inst": { + "name": "UQ_platform_integrations_owned_by_user_platform_inst", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_slack_platform_inst": { + "name": "UQ_platform_integrations_slack_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'slack' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_linear_platform_inst": { + "name": "UQ_platform_integrations_linear_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'linear' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_github_platform_inst": { + "name": "UQ_platform_integrations_github_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'github' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_user_bitbucket": { + "name": "UQ_platform_integrations_user_bitbucket", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'bitbucket' AND \"platform_integrations\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_org_bitbucket": { + "name": "UQ_platform_integrations_org_bitbucket", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'bitbucket' AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_id": { + "name": "IDX_platform_integrations_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_id": { + "name": "IDX_platform_integrations_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_inst_id": { + "name": "IDX_platform_integrations_platform_inst_id", + "columns": [ + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform": { + "name": "IDX_platform_integrations_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_platform": { + "name": "IDX_platform_integrations_owned_by_org_platform", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_platform": { + "name": "IDX_platform_integrations_owned_by_user_platform", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_integration_status": { + "name": "IDX_platform_integrations_integration_status", + "columns": [ + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_kilo_requester": { + "name": "IDX_platform_integrations_kilo_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_requester_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_requester": { + "name": "IDX_platform_integrations_platform_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_requester_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_integrations_owned_by_organization_id_organizations_id_fk": { + "name": "platform_integrations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_integrations_owned_by_user_id_kilocode_users_id_fk": { + "name": "platform_integrations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_integrations_owner_check": { + "name": "platform_integrations_owner_check", + "value": "(\n (\"platform_integrations\".\"owned_by_user_id\" IS NOT NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NULL) OR\n (\"platform_integrations\".\"owned_by_user_id\" IS NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.platform_oauth_credentials": { + "name": "platform_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subject_login": { + "name": "provider_subject_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_base_url": { + "name": "provider_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_oauth_credentials_platform_integration_id": { + "name": "UQ_platform_oauth_credentials_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_oauth_credentials_authorized_by_user_id": { + "name": "IDX_platform_oauth_credentials_authorized_by_user_id", + "columns": [ + { + "expression": "authorized_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_oauth_credentials_platform_integration_id_platform_integrations_id_fk": { + "name": "platform_oauth_credentials_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "platform_oauth_credentials", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_oauth_credentials_authorized_by_user_id_kilocode_users_id_fk": { + "name": "platform_oauth_credentials_authorized_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_oauth_credentials", + "tableTo": "kilocode_users", + "columnsFrom": [ + "authorized_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_oauth_credentials_credential_version_check": { + "name": "platform_oauth_credentials_credential_version_check", + "value": "\"platform_oauth_credentials\".\"credential_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.referral_code_usages": { + "name": "referral_code_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referring_kilo_user_id": { + "name": "referring_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeeming_kilo_user_id": { + "name": "redeeming_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_referral_code_usages_redeeming_kilo_user_id": { + "name": "IDX_referral_code_usages_redeeming_kilo_user_id", + "columns": [ + { + "expression": "redeeming_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_referral_code_usages_redeeming_user_id_code": { + "name": "UQ_referral_code_usages_redeeming_user_id_code", + "nullsNotDistinct": false, + "columns": [ + "redeeming_kilo_user_id", + "referring_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_referral_codes_kilo_user_id": { + "name": "UQ_referral_codes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_referral_codes_code": { + "name": "IDX_referral_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_check_catalog": { + "name": "security_advisor_check_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_check_catalog_check_id_unique": { + "name": "security_advisor_check_catalog_check_id_unique", + "nullsNotDistinct": false, + "columns": [ + "check_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_advisor_check_catalog_severity_check": { + "name": "security_advisor_check_catalog_severity_check", + "value": "\"security_advisor_check_catalog\".\"severity\" in ('critical', 'warn', 'info')" + } + }, + "isRLSEnabled": false + }, + "public.security_advisor_content": { + "name": "security_advisor_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_content_key_unique": { + "name": "security_advisor_content_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_kiloclaw_coverage": { + "name": "security_advisor_kiloclaw_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_check_ids": { + "name": "match_check_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_kiloclaw_coverage_area_unique": { + "name": "security_advisor_kiloclaw_coverage_area_unique", + "nullsNotDistinct": false, + "columns": [ + "area" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_scans": { + "name": "security_advisor_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_method": { + "name": "source_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_ip": { + "name": "public_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings_critical": { + "name": "findings_critical", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_warn": { + "name": "findings_warn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_info": { + "name": "findings_info", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_advisor_scans_user_created_at": { + "name": "idx_security_advisor_scans_user_created_at", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_created_at": { + "name": "idx_security_advisor_scans_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_platform": { + "name": "idx_security_advisor_scans_platform", + "columns": [ + { + "expression": "source_platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_agent_commands": { + "name": "security_agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'accepted'" + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_agent_commands_org_created": { + "name": "idx_security_agent_commands_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_user_created": { + "name": "idx_security_agent_commands_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_status_updated": { + "name": "idx_security_agent_commands_status_updated", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_finding_created": { + "name": "idx_security_agent_commands_finding_created", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_agent_commands_owned_by_organization_id_organizations_id_fk": { + "name": "security_agent_commands_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_commands_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_agent_commands_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_commands_finding_id_security_findings_id_fk": { + "name": "security_agent_commands_finding_id_security_findings_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_agent_commands_owner_check": { + "name": "security_agent_commands_owner_check", + "value": "(\n (\"security_agent_commands\".\"owned_by_user_id\" IS NOT NULL AND \"security_agent_commands\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_agent_commands\".\"owned_by_user_id\" IS NULL AND \"security_agent_commands\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_agent_commands_type_check": { + "name": "security_agent_commands_type_check", + "value": "\"security_agent_commands\".\"command_type\" IN ('sync', 'dismiss_finding', 'start_analysis', 'apply_auto_remediation')" + }, + "security_agent_commands_origin_check": { + "name": "security_agent_commands_origin_check", + "value": "\"security_agent_commands\".\"origin\" IN ('manual', 'dashboard_refresh', 'enable_initial_sync', 'settings_include_existing')" + }, + "security_agent_commands_status_check": { + "name": "security_agent_commands_status_check", + "value": "\"security_agent_commands\".\"status\" IN ('accepted', 'running', 'succeeded', 'failed', 'no_op')" + } + }, + "isRLSEnabled": false + }, + "public.security_agent_repository_sync_state": { + "name": "security_agent_repository_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_failure_code": { + "name": "last_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_agent_repository_sync_state_org_repo": { + "name": "UQ_security_agent_repository_sync_state_org_repo", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_repository_sync_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_agent_repository_sync_state_user_repo": { + "name": "UQ_security_agent_repository_sync_state_user_repo", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_repository_sync_state\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_agent_repository_sync_state_owned_by_organization_id_organizations_id_fk": { + "name": "security_agent_repository_sync_state_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_agent_repository_sync_state", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_repository_sync_state_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_agent_repository_sync_state_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_agent_repository_sync_state", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_agent_repository_sync_state_owner_check": { + "name": "security_agent_repository_sync_state_owner_check", + "value": "(\n (\"security_agent_repository_sync_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_agent_repository_sync_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_agent_repository_sync_state\".\"owned_by_user_id\" IS NULL AND \"security_agent_repository_sync_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_owner_state": { + "name": "security_analysis_owner_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_analysis_enabled_at": { + "name": "auto_analysis_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "block_reason": { + "name": "block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_actor_resolution_failures": { + "name": "consecutive_actor_resolution_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_actor_resolution_failure_at": { + "name": "last_actor_resolution_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_owner_state_org_owner": { + "name": "UQ_security_analysis_owner_state_org_owner", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_analysis_owner_state_user_owner": { + "name": "UQ_security_analysis_owner_state_user_owner", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_owner_state_owner_check": { + "name": "security_analysis_owner_state_owner_check", + "value": "(\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_owner_state_block_reason_check": { + "name": "security_analysis_owner_state_block_reason_check", + "value": "\"security_analysis_owner_state\".\"block_reason\" IS NULL OR \"security_analysis_owner_state\".\"block_reason\" IN ('INSUFFICIENT_CREDITS', 'ACTOR_RESOLUTION_FAILED', 'OPERATOR_PAUSE')" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_queue": { + "name": "security_analysis_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queue_status": { + "name": "queue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity_rank": { + "name": "severity_rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reopen_requeue_count": { + "name": "reopen_requeue_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_queue_finding_id": { + "name": "UQ_security_analysis_queue_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_org": { + "name": "idx_security_analysis_queue_claim_path_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_user": { + "name": "idx_security_analysis_queue_claim_path_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_org": { + "name": "idx_security_analysis_queue_in_flight_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_user": { + "name": "idx_security_analysis_queue_in_flight_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_lag_dashboards": { + "name": "idx_security_analysis_queue_lag_dashboards", + "columns": [ + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_pending_reconciliation": { + "name": "idx_security_analysis_queue_pending_reconciliation", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_running_reconciliation": { + "name": "idx_security_analysis_queue_running_reconciliation", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_failure_trend": { + "name": "idx_security_analysis_queue_failure_trend", + "columns": [ + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"failure_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_queue_finding_id_security_findings_id_fk": { + "name": "security_analysis_queue_finding_id_security_findings_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_queue_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_queue_owner_check": { + "name": "security_analysis_queue_owner_check", + "value": "(\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_queue_status_check": { + "name": "security_analysis_queue_status_check", + "value": "\"security_analysis_queue\".\"queue_status\" IN ('queued', 'pending', 'running', 'failed', 'completed')" + }, + "security_analysis_queue_claim_token_required_check": { + "name": "security_analysis_queue_claim_token_required_check", + "value": "\"security_analysis_queue\".\"queue_status\" NOT IN ('pending', 'running') OR \"security_analysis_queue\".\"claim_token\" IS NOT NULL" + }, + "security_analysis_queue_attempt_count_non_negative_check": { + "name": "security_analysis_queue_attempt_count_non_negative_check", + "value": "\"security_analysis_queue\".\"attempt_count\" >= 0" + }, + "security_analysis_queue_reopen_requeue_count_non_negative_check": { + "name": "security_analysis_queue_reopen_requeue_count_non_negative_check", + "value": "\"security_analysis_queue\".\"reopen_requeue_count\" >= 0" + }, + "security_analysis_queue_severity_rank_check": { + "name": "security_analysis_queue_severity_rank_check", + "value": "\"security_analysis_queue\".\"severity_rank\" IN (0, 1, 2, 3)" + }, + "security_analysis_queue_failure_code_check": { + "name": "security_analysis_queue_failure_code_check", + "value": "\"security_analysis_queue\".\"failure_code\" IS NULL OR \"security_analysis_queue\".\"failure_code\" IN (\n 'NETWORK_TIMEOUT',\n 'UPSTREAM_5XX',\n 'TEMP_TOKEN_FAILURE',\n 'START_CALL_AMBIGUOUS',\n 'REQUEUE_TEMPORARY_PRECONDITION',\n 'ACTOR_RESOLUTION_FAILED',\n 'GITHUB_TOKEN_UNAVAILABLE',\n 'INVALID_CONFIG',\n 'MISSING_OWNERSHIP',\n 'PERMISSION_DENIED_PERMANENT',\n 'UNSUPPORTED_SEVERITY',\n 'INSUFFICIENT_CREDITS',\n 'STATE_GUARD_REJECTED',\n 'SKIPPED_ALREADY_IN_PROGRESS',\n 'SKIPPED_NO_LONGER_ELIGIBLE',\n 'REOPEN_LOOP_GUARD',\n 'RUN_LOST'\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source_occurred_at": { + "name": "source_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "finding_snapshot": { + "name": "finding_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_security_audit_log_org_created": { + "name": "IDX_security_audit_log_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_created": { + "name": "IDX_security_audit_log_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_resource": { + "name": "IDX_security_audit_log_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_actor": { + "name": "IDX_security_audit_log_actor", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_action": { + "name": "IDX_security_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_audit_log_org_event_key": { + "name": "UQ_security_audit_log_org_event_key", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL AND \"security_audit_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_audit_log_user_event_key": { + "name": "UQ_security_audit_log_user_event_key", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_org_occurred": { + "name": "IDX_security_audit_log_org_occurred", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL AND \"security_audit_log\".\"occurred_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_occurred": { + "name": "IDX_security_audit_log_user_occurred", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"occurred_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_owned_by_organization_id_organizations_id_fk": { + "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_audit_log_owner_check": { + "name": "security_audit_log_owner_check", + "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" + }, + "security_audit_log_action_check": { + "name": "security_audit_log_action_check", + "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.severity_changed', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.superseded', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.analysis_failed', 'security.remediation.queued', 'security.remediation.started', 'security.remediation.pr_opened', 'security.remediation.failed', 'security.remediation.blocked', 'security.remediation.no_changes_needed', 'security.remediation.cancelled', 'security.remediation.retried', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported', 'security.audit_report.generated')" + }, + "security_audit_log_actor_type_check": { + "name": "security_audit_log_actor_type_check", + "value": "\"security_audit_log\".\"actor_type\" IN ('customer_user', 'kilo_admin', 'system')" + }, + "security_audit_log_source_context_check": { + "name": "security_audit_log_source_context_check", + "value": "\"security_audit_log\".\"source_context\" IN ('security_sync', 'web', 'analysis_worker', 'remediation_callback', 'rollout_baseline')" + } + }, + "isRLSEnabled": false + }, + "public.security_finding_notifications": { + "name": "security_finding_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'staged'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_security_finding_notifications_finding_recipient_kind": { + "name": "uq_security_finding_notifications_finding_recipient_kind", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_pending": { + "name": "idx_security_finding_notifications_pending", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_finding_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_staged": { + "name": "idx_security_finding_notifications_staged", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_finding_notifications\".\"status\" = 'staged'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_finding_id": { + "name": "idx_security_finding_notifications_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_recipient_user_id": { + "name": "idx_security_finding_notifications_recipient_user_id", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_finding_notifications_finding_fk": { + "name": "security_finding_notifications_finding_fk", + "tableFrom": "security_finding_notifications", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_finding_notifications_recipient_fk": { + "name": "security_finding_notifications_recipient_fk", + "tableFrom": "security_finding_notifications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_finding_notifications_kind_check": { + "name": "security_finding_notifications_kind_check", + "value": "\"security_finding_notifications\".\"kind\" IN ('new_finding', 'sla_warning', 'sla_breach')" + }, + "security_finding_notifications_status_check": { + "name": "security_finding_notifications_status_check", + "value": "\"security_finding_notifications\".\"status\" IN ('staged', 'pending', 'sending', 'sent', 'failed', 'cancelled')" + }, + "security_finding_notifications_attempt_count_check": { + "name": "security_finding_notifications_attempt_count_check", + "value": "\"security_finding_notifications\".\"attempt_count\" >= 0" + }, + "security_finding_notifications_claimed_at_check": { + "name": "security_finding_notifications_claimed_at_check", + "value": "(\n (\"security_finding_notifications\".\"status\" = 'sending' AND \"security_finding_notifications\".\"claimed_at\" IS NOT NULL) OR\n (\"security_finding_notifications\".\"status\" <> 'sending' AND \"security_finding_notifications\".\"claimed_at\" IS NULL)\n )" + }, + "security_finding_notifications_sent_at_check": { + "name": "security_finding_notifications_sent_at_check", + "value": "(\n (\"security_finding_notifications\".\"status\" = 'sent' AND \"security_finding_notifications\".\"sent_at\" IS NOT NULL) OR\n (\"security_finding_notifications\".\"status\" <> 'sent' AND \"security_finding_notifications\".\"sent_at\" IS NULL)\n )" + }, + "security_finding_notifications_error_message_length_check": { + "name": "security_finding_notifications_error_message_length_check", + "value": "\"security_finding_notifications\".\"error_message\" IS NULL OR length(\"security_finding_notifications\".\"error_message\") <= 500" + } + }, + "isRLSEnabled": false + }, + "public.security_findings": { + "name": "security_findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ghsa_id": { + "name": "ghsa_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cve_id": { + "name": "cve_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_ecosystem": { + "name": "package_ecosystem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vulnerable_version_range": { + "name": "vulnerable_version_range", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patched_version": { + "name": "patched_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_path": { + "name": "manifest_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "ignored_reason": { + "name": "ignored_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignored_by": { + "name": "ignored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fixed_at": { + "name": "fixed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sla_due_at": { + "name": "sla_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dependabot_html_url": { + "name": "dependabot_html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwe_ids": { + "name": "cwe_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cvss_score": { + "name": "cvss_score", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": false + }, + "dependency_scope": { + "name": "dependency_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_status": { + "name": "analysis_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_started_at": { + "name": "analysis_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_error": { + "name": "analysis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis": { + "name": "analysis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_security_findings_user_source": { + "name": "uq_security_findings_user_source", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_findings\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_security_findings_org_source": { + "name": "uq_security_findings_org_source", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_findings\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_org_id": { + "name": "idx_security_findings_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_id": { + "name": "idx_security_findings_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_repo": { + "name": "idx_security_findings_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_severity": { + "name": "idx_security_findings_severity", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_status": { + "name": "idx_security_findings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_package": { + "name": "idx_security_findings_package", + "columns": [ + { + "expression": "package_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_sla_due_at": { + "name": "idx_security_findings_sla_due_at", + "columns": [ + { + "expression": "sla_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_session_id": { + "name": "idx_security_findings_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_cli_session_id": { + "name": "idx_security_findings_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_analysis_status": { + "name": "idx_security_findings_analysis_status", + "columns": [ + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_org_analysis_in_flight": { + "name": "idx_security_findings_org_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_analysis_in_flight": { + "name": "idx_security_findings_user_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_findings_owned_by_organization_id_organizations_id_fk": { + "name": "security_findings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_findings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_findings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_findings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_platform_integration_id_platform_integrations_id_fk": { + "name": "security_findings_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "security_findings", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_findings_owner_check": { + "name": "security_findings_owner_check", + "value": "(\n (\"security_findings\".\"owned_by_user_id\" IS NOT NULL AND \"security_findings\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_findings\".\"owned_by_user_id\" IS NULL AND \"security_findings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_remediation_attempts": { + "name": "security_remediation_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "remediation_id": { + "name": "remediation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retry_of_attempt_id": { + "name": "retry_of_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_fingerprint": { + "name": "analysis_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "remediation_model_slug": { + "name": "remediation_model_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_attempt_count": { + "name": "launch_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "callback_attempt_token_hash": { + "name": "callback_attempt_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "structured_result": { + "name": "structured_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "final_assistant_message": { + "name": "final_assistant_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_evidence": { + "name": "validation_evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_notes": { + "name": "risk_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_reason": { + "name": "draft_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_draft": { + "name": "pr_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pr_head_branch": { + "name": "pr_head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_branch": { + "name": "pr_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_at": { + "name": "cancellation_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_by_user_id": { + "name": "cancellation_requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_remediation_attempts_number": { + "name": "UQ_security_remediation_attempts_number", + "columns": [ + { + "expression": "remediation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_active_finding": { + "name": "UQ_security_remediation_attempts_active_finding", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_active_remediation": { + "name": "UQ_security_remediation_attempts_active_remediation", + "columns": [ + { + "expression": "remediation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_finding_fingerprint_terminal": { + "name": "UQ_security_remediation_attempts_finding_fingerprint_terminal", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running', 'pr_opened')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_org_claim": { + "name": "idx_security_remediation_attempts_org_claim", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_user_claim": { + "name": "idx_security_remediation_attempts_user_claim", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_repo_claim": { + "name": "idx_security_remediation_attempts_repo_claim", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_org_inflight": { + "name": "idx_security_remediation_attempts_org_inflight", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_user_inflight": { + "name": "idx_security_remediation_attempts_user_inflight", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_repo_inflight": { + "name": "idx_security_remediation_attempts_repo_inflight", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_cloud_agent_session": { + "name": "idx_security_remediation_attempts_cloud_agent_session", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_finding_fingerprint": { + "name": "idx_security_remediation_attempts_finding_fingerprint", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_remediation_attempts_remediation_id_security_remediations_id_fk": { + "name": "security_remediation_attempts_remediation_id_security_remediations_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "security_remediations", + "columnsFrom": [ + "remediation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_finding_id_security_findings_id_fk": { + "name": "security_remediation_attempts_finding_id_security_findings_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_owned_by_organization_id_organizations_id_fk": { + "name": "security_remediation_attempts_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_requested_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_requested_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "security_remediation_attempts_cancellation_requested_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_cancellation_requested_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "cancellation_requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_remediation_attempts_owner_check": { + "name": "security_remediation_attempts_owner_check", + "value": "(\n (\"security_remediation_attempts\".\"owned_by_user_id\" IS NOT NULL AND \"security_remediation_attempts\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_remediation_attempts\".\"owned_by_user_id\" IS NULL AND \"security_remediation_attempts\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_remediation_attempts_status_check": { + "name": "security_remediation_attempts_status_check", + "value": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running', 'pr_opened', 'failed', 'blocked', 'no_changes_needed', 'cancelled')" + }, + "security_remediation_attempts_origin_check": { + "name": "security_remediation_attempts_origin_check", + "value": "\"security_remediation_attempts\".\"origin\" IN ('auto_policy', 'bulk_existing', 'manual')" + }, + "security_remediation_attempts_attempt_number_check": { + "name": "security_remediation_attempts_attempt_number_check", + "value": "\"security_remediation_attempts\".\"attempt_number\" >= 1" + }, + "security_remediation_attempts_launch_attempt_count_check": { + "name": "security_remediation_attempts_launch_attempt_count_check", + "value": "\"security_remediation_attempts\".\"launch_attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.security_remediations": { + "name": "security_remediations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "latest_attempt_id": { + "name": "latest_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_analysis_fingerprint": { + "name": "latest_analysis_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_analysis_completed_at": { + "name": "latest_analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_draft": { + "name": "pr_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pr_head_branch": { + "name": "pr_head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_branch": { + "name": "pr_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome_summary": { + "name": "outcome_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_remediations_finding_id": { + "name": "UQ_security_remediations_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_org_status": { + "name": "idx_security_remediations_org_status", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_user_status": { + "name": "idx_security_remediations_user_status", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_repo_status": { + "name": "idx_security_remediations_repo_status", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_latest_attempt": { + "name": "idx_security_remediations_latest_attempt", + "columns": [ + { + "expression": "latest_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_remediations_owned_by_organization_id_organizations_id_fk": { + "name": "security_remediations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_remediations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediations_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_remediations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediations_finding_id_security_findings_id_fk": { + "name": "security_remediations_finding_id_security_findings_id_fk", + "tableFrom": "security_remediations", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_remediations_owner_check": { + "name": "security_remediations_owner_check", + "value": "(\n (\"security_remediations\".\"owned_by_user_id\" IS NOT NULL AND \"security_remediations\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_remediations\".\"owned_by_user_id\" IS NULL AND \"security_remediations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_remediations_status_check": { + "name": "security_remediations_status_check", + "value": "\"security_remediations\".\"status\" IN ('queued', 'running', 'pr_opened', 'failed', 'blocked', 'no_changes_needed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.shared_cli_sessions": { + "name": "shared_cli_sessions", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_state": { + "name": "shared_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_shared_cli_sessions_session_id": { + "name": "IDX_shared_cli_sessions_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_shared_cli_sessions_created_at": { + "name": "IDX_shared_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_cli_sessions_session_id_cli_sessions_session_id_fk": { + "name": "shared_cli_sessions_session_id_cli_sessions_session_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_cli_sessions_shared_state_check": { + "name": "shared_cli_sessions_shared_state_check", + "value": "\"shared_cli_sessions\".\"shared_state\" IN ('public', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.slack_bot_requests": { + "name": "slack_bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_truncated": { + "name": "user_message_truncated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls_made": { + "name": "tool_calls_made", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_slack_bot_requests_created_at": { + "name": "idx_slack_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_slack_team_id": { + "name": "idx_slack_bot_requests_slack_team_id", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_org_id": { + "name": "idx_slack_bot_requests_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_user_id": { + "name": "idx_slack_bot_requests_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_status": { + "name": "idx_slack_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_event_type": { + "name": "idx_slack_bot_requests_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_team_created": { + "name": "idx_slack_bot_requests_team_created", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_bot_requests_owned_by_organization_id_organizations_id_fk": { + "name": "slack_bot_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "slack_bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_bot_requests_owner_check": { + "name": "slack_bot_requests_owner_check", + "value": "(\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NOT NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NOT NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.source_embeddings": { + "name": "source_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_base_branch": { + "name": "is_base_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_source_embeddings_organization_id": { + "name": "IDX_source_embeddings_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_kilo_user_id": { + "name": "IDX_source_embeddings_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_project_id": { + "name": "IDX_source_embeddings_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_created_at": { + "name": "IDX_source_embeddings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_updated_at": { + "name": "IDX_source_embeddings_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_file_path_lower": { + "name": "IDX_source_embeddings_file_path_lower", + "columns": [ + { + "expression": "LOWER(\"file_path\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_git_branch": { + "name": "IDX_source_embeddings_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_org_project_branch": { + "name": "IDX_source_embeddings_org_project_branch", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_embeddings_organization_id_organizations_id_fk": { + "name": "source_embeddings_organization_id_organizations_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_embeddings_kilo_user_id_kilocode_users_id_fk": { + "name": "source_embeddings_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_source_embeddings_org_project_branch_file_lines": { + "name": "UQ_source_embeddings_org_project_branch_file_lines", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "project_id", + "git_branch", + "file_path", + "start_line", + "end_line" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_dispute_actions": { + "name": "stripe_dispute_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_reference_id": { + "name": "result_reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_dispute_actions_case_id": { + "name": "IDX_stripe_dispute_actions_case_id", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_actions_claim_path": { + "name": "IDX_stripe_dispute_actions_claim_path", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_dispute_actions_case_id_stripe_dispute_cases_id_fk": { + "name": "stripe_dispute_actions_case_id_stripe_dispute_cases_id_fk", + "tableFrom": "stripe_dispute_actions", + "tableTo": "stripe_dispute_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_dispute_actions_case_type_target": { + "name": "UQ_stripe_dispute_actions_case_type_target", + "nullsNotDistinct": false, + "columns": [ + "case_id", + "action_type", + "target_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_dispute_actions_action_type_check": { + "name": "stripe_dispute_actions_action_type_check", + "value": "\"stripe_dispute_actions\".\"action_type\" IN ('stripe_acceptance', 'user_block', 'auto_top_up_disable', 'credit_balance_reset', 'subscription_cancellation', 'access_termination', 'kiloclaw_suspension')" + }, + "stripe_dispute_actions_status_check": { + "name": "stripe_dispute_actions_status_check", + "value": "\"stripe_dispute_actions\".\"status\" IN ('queued', 'processing', 'completed', 'failed', 'skipped')" + }, + "stripe_dispute_actions_attempt_count_non_negative_check": { + "name": "stripe_dispute_actions_attempt_count_non_negative_check", + "value": "\"stripe_dispute_actions\".\"attempt_count\" >= 0" + }, + "stripe_dispute_actions_target_key_not_empty_check": { + "name": "stripe_dispute_actions_target_key_not_empty_check", + "value": "length(\"stripe_dispute_actions\".\"target_key\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_dispute_cases": { + "name": "stripe_dispute_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_dispute_id": { + "name": "stripe_dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_created_at": { + "name": "stripe_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor_units": { + "name": "amount_minor_units", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispute_reason": { + "name": "dispute_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_status": { + "name": "stripe_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_classification": { + "name": "owner_classification", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'needs_action'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_created_at": { + "name": "stripe_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evidence_due_by": { + "name": "evidence_due_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_kilo_user_id": { + "name": "accepted_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance_started_at": { + "name": "acceptance_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enforcement_completed_at": { + "name": "enforcement_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_required_at": { + "name": "review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_dispute_cases_event_id": { + "name": "IDX_stripe_dispute_cases_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_charge_id": { + "name": "IDX_stripe_dispute_cases_charge_id", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_payment_intent_id": { + "name": "IDX_stripe_dispute_cases_payment_intent_id", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_customer_id": { + "name": "IDX_stripe_dispute_cases_customer_id", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_kilo_user_id": { + "name": "IDX_stripe_dispute_cases_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_organization_id": { + "name": "IDX_stripe_dispute_cases_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_status_due_by": { + "name": "IDX_stripe_dispute_cases_status_due_by", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "evidence_due_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_dispute_cases_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_dispute_cases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_dispute_cases_organization_id_organizations_id_fk": { + "name": "stripe_dispute_cases_organization_id_organizations_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_dispute_cases_accepted_by_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_dispute_cases_accepted_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "accepted_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_dispute_cases_dispute_id": { + "name": "UQ_stripe_dispute_cases_dispute_id", + "nullsNotDistinct": false, + "columns": [ + "stripe_dispute_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_dispute_cases_owner_classification_check": { + "name": "stripe_dispute_cases_owner_classification_check", + "value": "\"stripe_dispute_cases\".\"owner_classification\" IN ('personal', 'organization', 'ambiguous', 'unmatched')" + }, + "stripe_dispute_cases_status_check": { + "name": "stripe_dispute_cases_status_check", + "value": "\"stripe_dispute_cases\".\"status\" IN ('needs_action', 'processing', 'accepted', 'acceptance_failed', 'enforcement_failed', 'review_required', 'closed')" + }, + "stripe_dispute_cases_amount_minor_units_non_negative_check": { + "name": "stripe_dispute_cases_amount_minor_units_non_negative_check", + "value": "\"stripe_dispute_cases\".\"amount_minor_units\" IS NULL OR \"stripe_dispute_cases\".\"amount_minor_units\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_early_fraud_warning_actions": { + "name": "stripe_early_fraud_warning_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_reference_id": { + "name": "result_reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_early_fraud_warning_actions_case_id": { + "name": "IDX_stripe_early_fraud_warning_actions_case_id", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_actions_claim_path": { + "name": "IDX_stripe_early_fraud_warning_actions_claim_path", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_early_fraud_warning_actions_case_id_stripe_early_fraud_warning_cases_id_fk": { + "name": "stripe_early_fraud_warning_actions_case_id_stripe_early_fraud_warning_cases_id_fk", + "tableFrom": "stripe_early_fraud_warning_actions", + "tableTo": "stripe_early_fraud_warning_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_early_fraud_warning_actions_case_type_target": { + "name": "UQ_stripe_early_fraud_warning_actions_case_type_target", + "nullsNotDistinct": false, + "columns": [ + "case_id", + "action_type", + "target_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_early_fraud_warning_actions_action_type_check": { + "name": "stripe_early_fraud_warning_actions_action_type_check", + "value": "\"stripe_early_fraud_warning_actions\".\"action_type\" IN ('containment', 'refund', 'payment_value_clawback', 'subscription_termination', 'access_termination', 'kiloclaw_suspension', 'affiliate_payout_reversal', 'referral_reward_reversal', 'user_notice')" + }, + "stripe_early_fraud_warning_actions_status_check": { + "name": "stripe_early_fraud_warning_actions_status_check", + "value": "\"stripe_early_fraud_warning_actions\".\"status\" IN ('queued', 'processing', 'completed', 'failed', 'review_required', 'dismissed')" + }, + "stripe_early_fraud_warning_actions_attempt_count_non_negative_check": { + "name": "stripe_early_fraud_warning_actions_attempt_count_non_negative_check", + "value": "\"stripe_early_fraud_warning_actions\".\"attempt_count\" >= 0" + }, + "stripe_early_fraud_warning_actions_target_key_not_empty_check": { + "name": "stripe_early_fraud_warning_actions_target_key_not_empty_check", + "value": "length(\"stripe_early_fraud_warning_actions\".\"target_key\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_early_fraud_warning_cases": { + "name": "stripe_early_fraud_warning_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_early_fraud_warning_id": { + "name": "stripe_early_fraud_warning_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor_units": { + "name": "amount_minor_units", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_classification": { + "name": "owner_classification", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "warning_created_at": { + "name": "warning_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contained_at": { + "name": "contained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_required_at": { + "name": "review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "remediated_at": { + "name": "remediated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_early_fraud_warning_cases_event_id": { + "name": "IDX_stripe_early_fraud_warning_cases_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_charge_id": { + "name": "IDX_stripe_early_fraud_warning_cases_charge_id", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_payment_intent_id": { + "name": "IDX_stripe_early_fraud_warning_cases_payment_intent_id", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_customer_id": { + "name": "IDX_stripe_early_fraud_warning_cases_customer_id", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_kilo_user_id": { + "name": "IDX_stripe_early_fraud_warning_cases_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_organization_id": { + "name": "IDX_stripe_early_fraud_warning_cases_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_status_created_at": { + "name": "IDX_stripe_early_fraud_warning_cases_status_created_at", + "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": { + "stripe_early_fraud_warning_cases_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_early_fraud_warning_cases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_early_fraud_warning_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_early_fraud_warning_cases_organization_id_organizations_id_fk": { + "name": "stripe_early_fraud_warning_cases_organization_id_organizations_id_fk", + "tableFrom": "stripe_early_fraud_warning_cases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_early_fraud_warning_cases_warning_id": { + "name": "UQ_stripe_early_fraud_warning_cases_warning_id", + "nullsNotDistinct": false, + "columns": [ + "stripe_early_fraud_warning_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_early_fraud_warning_cases_owner_classification_check": { + "name": "stripe_early_fraud_warning_cases_owner_classification_check", + "value": "\"stripe_early_fraud_warning_cases\".\"owner_classification\" IN ('personal', 'organization', 'ambiguous', 'unmatched')" + }, + "stripe_early_fraud_warning_cases_status_check": { + "name": "stripe_early_fraud_warning_cases_status_check", + "value": "\"stripe_early_fraud_warning_cases\".\"status\" IN ('queued', 'contained', 'processing', 'completed', 'review_required', 'failed', 'remediated', 'dismissed')" + }, + "stripe_early_fraud_warning_cases_amount_minor_units_non_negative_check": { + "name": "stripe_early_fraud_warning_cases_amount_minor_units_non_negative_check", + "value": "\"stripe_early_fraud_warning_cases\".\"amount_minor_units\" IS NULL OR \"stripe_early_fraud_warning_cases\".\"amount_minor_units\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stytch_fingerprints": { + "name": "stytch_fingerprints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_fingerprint": { + "name": "visitor_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_fingerprint": { + "name": "browser_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_id": { + "name": "browser_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hardware_fingerprint": { + "name": "hardware_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network_fingerprint": { + "name": "network_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_action": { + "name": "verdict_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_device_type": { + "name": "detected_device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_authentic_device": { + "name": "is_authentic_device", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint_data": { + "name": "fingerprint_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_free_tier_allowed": { + "name": "kilo_free_tier_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_hardware_fingerprint": { + "name": "idx_hardware_fingerprint", + "columns": [ + { + "expression": "hardware_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id": { + "name": "idx_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stytch_fingerprints_reasons_gin": { + "name": "idx_stytch_fingerprints_reasons_gin", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_verdict_action": { + "name": "idx_verdict_action", + "columns": [ + { + "expression": "verdict_action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_visitor_fingerprint": { + "name": "idx_visitor_fingerprint", + "columns": [ + { + "expression": "visitor_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompt_prefix": { + "name": "system_prompt_prefix", + "schema": "", + "columns": { + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_system_prompt_prefix": { + "name": "UQ_system_prompt_prefix", + "columns": [ + { + "expression": "system_prompt_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactional_email_log": { + "name": "transactional_email_log", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_transactional_email_log_type_idempotency_key": { + "name": "UQ_transactional_email_log_type_idempotency_key", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_user_id": { + "name": "IDX_transactional_email_log_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_organization_id": { + "name": "IDX_transactional_email_log_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_email_log_user_id_kilocode_users_id_fk": { + "name": "transactional_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactional_email_log_organization_id_organizations_id_fk": { + "name": "transactional_email_log_organization_id_organizations_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_transactional_email_log_owner": { + "name": "CHK_transactional_email_log_owner", + "value": "\"transactional_email_log\".\"user_id\" IS NOT NULL OR \"transactional_email_log\".\"organization_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.user_admin_notes": { + "name": "user_admin_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note_content": { + "name": "note_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_kilo_user_id": { + "name": "admin_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_34517df0b385234babc38fe81b": { + "name": "IDX_34517df0b385234babc38fe81b", + "columns": [ + { + "expression": "admin_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_ccbde98c4c14046daa5682ec4f": { + "name": "IDX_ccbde98c4c14046daa5682ec4f", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_d0270eb24ef6442d65a0b7853c": { + "name": "IDX_d0270eb24ef6442d65a0b7853c", + "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.user_affiliate_attributions": { + "name": "user_affiliate_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_attributions_user_id": { + "name": "IDX_user_affiliate_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_attributions_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_attributions_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_attributions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_attributions_user_provider": { + "name": "UQ_user_affiliate_attributions_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_attributions_provider_check": { + "name": "user_affiliate_attributions_provider_check", + "value": "\"user_affiliate_attributions\".\"provider\" IN ('impact')" + } + }, + "isRLSEnabled": false + }, + "public.user_affiliate_events": { + "name": "user_affiliate_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_event_id": { + "name": "parent_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_action_id": { + "name": "impact_action_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_submission_uri": { + "name": "impact_submission_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_events_claim_path": { + "name": "IDX_user_affiliate_events_claim_path", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_parent_event_id": { + "name": "IDX_user_affiliate_events_parent_event_id", + "columns": [ + { + "expression": "parent_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_provider_event_type_charge": { + "name": "IDX_user_affiliate_events_provider_event_type_charge", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_events_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_events_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_affiliate_events_parent_event_id_fk": { + "name": "user_affiliate_events_parent_event_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "user_affiliate_events", + "columnsFrom": [ + "parent_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_events_dedupe_key": { + "name": "UQ_user_affiliate_events_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_events_provider_check": { + "name": "user_affiliate_events_provider_check", + "value": "\"user_affiliate_events\".\"provider\" IN ('impact')" + }, + "user_affiliate_events_event_type_check": { + "name": "user_affiliate_events_event_type_check", + "value": "\"user_affiliate_events\".\"event_type\" IN ('signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal')" + }, + "user_affiliate_events_delivery_state_check": { + "name": "user_affiliate_events_delivery_state_check", + "value": "\"user_affiliate_events\".\"delivery_state\" IN ('queued', 'blocked', 'sending', 'delivered', 'failed')" + }, + "user_affiliate_events_attempt_count_non_negative_check": { + "name": "user_affiliate_events_attempt_count_non_negative_check", + "value": "\"user_affiliate_events\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_auth_provider": { + "name": "user_auth_provider", + "schema": "", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_auth_provider_kilo_user_id": { + "name": "IDX_user_auth_provider_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_auth_provider_hosted_domain": { + "name": "IDX_user_auth_provider_hosted_domain", + "columns": [ + { + "expression": "hosted_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_auth_provider_provider_provider_account_id_pk": { + "name": "user_auth_provider_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_feedback": { + "name": "user_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback_for": { + "name": "feedback_for", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "feedback_batch": { + "name": "feedback_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_feedback_created_at": { + "name": "IDX_user_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_kilo_user_id": { + "name": "IDX_user_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_for": { + "name": "IDX_user_feedback_feedback_for", + "columns": [ + { + "expression": "feedback_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_batch": { + "name": "IDX_user_feedback_feedback_batch", + "columns": [ + { + "expression": "feedback_batch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_source": { + "name": "IDX_user_feedback_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "user_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_github_app_tokens": { + "name": "user_github_app_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "github_user_id": { + "name": "github_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_github_app_tokens_user_app": { + "name": "UQ_user_github_app_tokens_user_app", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_github_app_tokens_github_user_app": { + "name": "UQ_user_github_app_tokens_github_user_app", + "columns": [ + { + "expression": "github_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_github_app_tokens_kilo_user_id_kilocode_users_id_fk": { + "name": "user_github_app_tokens_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_github_app_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_github_app_tokens_app_type_check": { + "name": "user_github_app_tokens_app_type_check", + "value": "\"user_github_app_tokens\".\"github_app_type\" IN ('standard', 'lite')" + } + }, + "isRLSEnabled": false + }, + "public.user_model_preferences": { + "name": "user_model_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favorites": { + "name": "favorites", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_selected": { + "name": "last_selected", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_model_preferences_user_id": { + "name": "UQ_user_model_preferences_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_model_preferences_user_id_kilocode_users_id_fk": { + "name": "user_model_preferences_user_id_kilocode_users_id_fk", + "tableFrom": "user_model_preferences", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_preferences": { + "name": "user_notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_push_enabled": { + "name": "agent_push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "chat_messages_enabled": { + "name": "chat_messages_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_attention_enabled": { + "name": "agent_attention_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "session_status_enabled": { + "name": "session_status_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "kiloclaw_activity_enabled": { + "name": "kiloclaw_activity_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "balance_alerts_enabled": { + "name": "balance_alerts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "security_findings_enabled": { + "name": "security_findings_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notification_preferences_user_id_kilocode_users_id_fk": { + "name": "user_notification_preferences_user_id_kilocode_users_id_fk", + "tableFrom": "user_notification_preferences", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_period_cache": { + "name": "user_period_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cache_type": { + "name": "cache_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shared_url_token": { + "name": "shared_url_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_user_period_cache_kilo_user_id": { + "name": "IDX_user_period_cache_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache": { + "name": "UQ_user_period_cache", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_period_cache_lookup": { + "name": "IDX_user_period_cache_lookup", + "columns": [ + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache_share_token": { + "name": "UQ_user_period_cache_share_token", + "columns": [ + { + "expression": "shared_url_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_period_cache\".\"shared_url_token\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_period_cache_kilo_user_id_kilocode_users_id_fk": { + "name": "user_period_cache_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_period_cache", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_period_cache_period_type_check": { + "name": "user_period_cache_period_type_check", + "value": "\"user_period_cache\".\"period_type\" IN ('year', 'quarter', 'month', 'week', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.user_push_tokens": { + "name": "user_push_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_push_tokens_token": { + "name": "UQ_user_push_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_push_tokens_user_id": { + "name": "IDX_user_push_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_push_tokens_user_id_kilocode_users_id_fk": { + "name": "user_push_tokens_user_id_kilocode_users_id_fk", + "tableFrom": "user_push_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_city": { + "name": "vercel_ip_city", + "schema": "", + "columns": { + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_city": { + "name": "vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_city": { + "name": "UQ_vercel_ip_city", + "columns": [ + { + "expression": "vercel_ip_city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_country": { + "name": "vercel_ip_country", + "schema": "", + "columns": { + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_country": { + "name": "vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_country": { + "name": "UQ_vercel_ip_country", + "columns": [ + { + "expression": "vercel_ip_country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_action": { + "name": "event_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handlers_triggered": { + "name": "handlers_triggered", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_signature": { + "name": "event_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_webhook_events_owned_by_org_id": { + "name": "IDX_webhook_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_owned_by_user_id": { + "name": "IDX_webhook_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_platform": { + "name": "IDX_webhook_events_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_event_type": { + "name": "IDX_webhook_events_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_created_at": { + "name": "IDX_webhook_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_owned_by_organization_id_organizations_id_fk": { + "name": "webhook_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "webhook_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "webhook_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "webhook_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_webhook_events_signature": { + "name": "UQ_webhook_events_signature", + "nullsNotDistinct": false, + "columns": [ + "event_signature" + ] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_events_owner_check": { + "name": "webhook_events_owner_check", + "value": "(\n (\"webhook_events\".\"owned_by_user_id\" IS NOT NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"webhook_events\".\"owned_by_user_id\" IS NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.microdollar_usage_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "abuse_delay": { + "name": "abuse_delay", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abuse_downgraded_from": { + "name": "abuse_downgraded_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n ak.api_kind,\n meta.has_tools,\n meta.machine_id,\n feat.feature,\n meta.session_id,\n md.mode,\n am.auto_model,\n meta.market_cost,\n meta.is_free,\n meta.abuse_delay,\n meta.abuse_downgraded_from\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"api_kind\" ak ON meta.api_kind_id = ak.api_kind_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n LEFT JOIN \"mode\" md ON meta.mode_id = md.mode_id\n LEFT JOIN \"auto_model\" am ON meta.auto_model_id = am.auto_model_id\n", + "name": "microdollar_usage_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 16fd2fbe56..70f8f184b5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1443,6 +1443,13 @@ "when": 1785934676925, "tag": "0205_device_auth_hardening", "breakpoints": true + }, + { + "idx": 206, + "version": "7", + "when": 1785964975524, + "tag": "0206_operation_ledgers_and_analytics_outbox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/operation-ledger.ts b/packages/db/src/operation-ledger.ts new file mode 100644 index 0000000000..023a7e60eb --- /dev/null +++ b/packages/db/src/operation-ledger.ts @@ -0,0 +1,595 @@ +/** + * Shared per-intent operation ledger (P1-A-08a / DEC-01). + * + * One row per `(kilo_user_id, domain, operation_key)` identity. Admission is + * concurrent-safe: exactly one caller admits, the rest receive the typed + * duplicate/takeover outcome and never re-execute the effect. Terminal settles + * are CAS from `admitted | reconcile_pending`; a second settle is a no-op. + * The analytics outbox row is written in the same transaction as the settle, + * so settle-plus-outbox is atomic, and ONLY the helpers in this file insert + * `analytics_event_outbox` rows (grep-enforced invariant). + * + * Retention: rows expire `LEDGER_RETENTION_DAYS` (30) after `admitted_at`. An + * expired row (any status) is deleted and re-admitted by the next admit. + * `canonical_result` is bounded at `MAX_CANONICAL_RESULT_BYTES` (4096) + * serialized bytes; the settle helper rejects larger payloads. + * + * Event identity: outbox `event_uuid` is a deterministic UUIDv5 of the UTF-8 + * string `` `${ledger_row_id}:${event_name}` `` under the fixed namespace + * literal `EVENT_UUID_NAMESPACE`. No `uuid` package is in the lockfile, so + * the UUIDv5 is computed with a small WebCrypto SHA-1 digest helper. + */ +import { and, eq, inArray, sql } from 'drizzle-orm'; +import type { ExtractTablesWithRelations } from 'drizzle-orm'; +import type { NodePgDatabase, NodePgQueryResultHKT } from 'drizzle-orm/node-postgres'; +import type { PgTransaction } from 'drizzle-orm/pg-core'; +import { ANALYTICS_EVENT_SCHEMAS } from '@kilocode/app-shared/analytics'; +import type { AnalyticsEventMap, TerminalOutcomeEventName } from '@kilocode/app-shared/analytics'; + +import type * as schema from './schema'; +import { + analytics_event_outbox, + operation_ledgers, + type AnalyticsEventOutboxRow, + type NewAnalyticsEventOutboxRow, + type NewOperationLedgerRow, + type OperationLedgerRow, +} from './schema'; + +// ----- constants ----------------------------------------------------------- + +/** Ledger rows are retained 30 days after admission (the dedupe window). */ +export const LEDGER_RETENTION_DAYS = 30; + +/** `canonical_result` is bounded at 4096 serialized bytes (DEC-01). */ +export const MAX_CANONICAL_RESULT_BYTES = 4096; + +/** Fixed UUIDv5 namespace literal for outbox event identities (DEC-01). */ +export const EVENT_UUID_NAMESPACE = 'c3a4f8e0-8e34-45b2-9c1d-7a2b5e6d4f10'; + +/** Mutation taxonomy (DEC-01). */ +export const OPERATION_TAXONOMIES = ['safe-retry', 'reconcile-first', 'never-replay'] as const; +export type OperationTaxonomy = (typeof OPERATION_TAXONOMIES)[number]; + +/** Ledger domains. `create_remote` session identity lives in the DO, not here. */ +export const OPERATION_DOMAINS = ['session', 'pr', 'security', 'organization'] as const; +export type OperationDomain = (typeof OPERATION_DOMAINS)[number]; + +export const OPERATION_TERMINAL_STATUSES = [ + 'completed', + 'failed', + 'no_op', + 'interrupted', + 'superseded', +] as const; +export type TerminalOperationStatus = (typeof OPERATION_TERMINAL_STATUSES)[number]; + +export const OPERATION_NON_TERMINAL_STATUSES = ['admitted', 'reconcile_pending'] as const; +export type NonTerminalOperationStatus = (typeof OPERATION_NON_TERMINAL_STATUSES)[number]; + +export const OPERATION_STATUSES = [ + ...OPERATION_NON_TERMINAL_STATUSES, + ...OPERATION_TERMINAL_STATUSES, +] as const; +export type OperationStatus = (typeof OPERATION_STATUSES)[number]; + +export function isTerminalOperationStatus(status: string): status is TerminalOperationStatus { + return (OPERATION_TERMINAL_STATUSES as readonly string[]).includes(status); +} + +// ----- connection types ------------------------------------------------------ + +export type LedgerTransaction = PgTransaction< + NodePgQueryResultHKT, + typeof schema, + ExtractTablesWithRelations +>; + +/** Accepts either a `NodePgDatabase` or an open transaction. */ +export type LedgerDatabase = NodePgDatabase | LedgerTransaction; + +// ----- public errors --------------------------------------------------------- + +/** Thrown when `canonical_result` would exceed 4096 serialized bytes. */ +export class CanonicalResultTooLargeError extends Error { + constructor(bytes: number) { + super( + `Operation ledger canonical_result is ${bytes} serialized bytes; the limit is ${MAX_CANONICAL_RESULT_BYTES}` + ); + this.name = 'CanonicalResultTooLargeError'; + } +} + +/** Thrown when an outbox event payload fails the shared catalog schema. */ +export class OutboxEventValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'OutboxEventValidationError'; + } +} + +// ----- input / output types --------------------------------------------------- + +export type AdmitOperationInput = { + /** The acting user. */ + userId: string; + /** Organization context when the operation is organization-scoped. */ + orgId?: string | null; + domain: OperationDomain; + intent: string; + /** Client-generated UUID, stable across retries of one user intent. */ + operationKey: string; + /** Domain resource identity (for example `owner/repo#number`). Never analytics. */ + resourceKey?: string | null; + taxonomy: OperationTaxonomy; + /** Lease duration for the `admitted` claim, in seconds. */ + leaseSeconds: number; +}; + +export type AdmitOperationResult = + | { admission: 'admitted'; row: OperationLedgerRow } + | { admission: 'duplicate_settled'; row: OperationLedgerRow } + | { admission: 'duplicate_in_flight'; row: OperationLedgerRow } + | { admission: 'takeover'; row: OperationLedgerRow } + | { admission: 'duplicate_reconcile_pending'; row: OperationLedgerRow }; + +/** Terminal outbox event input, correlated by event name. */ +export type OutboxEventInput = { + [K in TerminalOutcomeEventName]: { + eventName: K; + /** Identity channel (the user's email), not an event property. */ + distinctId: string; + properties: AnalyticsEventMap[K]; + }; +}[TerminalOutcomeEventName]; + +export type SettleOperationInput = { + rowId: string; + status: TerminalOperationStatus; + outcomeCode?: string | null; + canonicalResult?: Record | null; + outboxEvent?: OutboxEventInput | null; +}; + +export type SettleOperationResult = + | { settled: true; row: OperationLedgerRow } + | { settled: false; row: OperationLedgerRow | null }; + +export type MarkReconcilePendingInput = { + rowId: string; + outboxEvent?: OutboxEventInput | null; +}; + +// ----- unit-of-work helpers ---------------------------------------------------- + +/** True when `value` is a database instance rather than an open transaction. */ +function isDatabase(database: LedgerDatabase): database is NodePgDatabase { + // `drizzle()` attaches `$client` to database instances; transactions never have it. + return typeof (database as { $client?: unknown }).$client !== 'undefined'; +} + +/** Runs `work` in its own transaction, or inline when given an open transaction. */ +async function runInTransaction( + database: LedgerDatabase, + work: (tx: LedgerTransaction) => Promise +): Promise { + if (isDatabase(database)) { + return database.transaction(work); + } + return work(database); +} + +// ----- admission ---------------------------------------------------------------- + +function admissionInsertValues(input: AdmitOperationInput, now: Date): NewOperationLedgerRow { + return { + operation_key: input.operationKey, + domain: input.domain, + intent: input.intent, + kilo_user_id: input.userId, + organization_id: input.orgId ?? null, + resource_key: input.resourceKey ?? null, + taxonomy: input.taxonomy, + status: 'admitted', + admitted_at: now.toISOString(), + lease_expires_at: new Date(now.getTime() + input.leaseSeconds * 1000).toISOString(), + expires_at: new Date(now.getTime() + LEDGER_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString(), + }; +} + +/** + * Admits an operation. Concurrent same-key admits produce exactly one + * `admitted` row; the loser receives the typed duplicate outcome. An expired + * row (`expires_at` past, any status) is deleted and re-inserted in one + * transaction. A live-lease `admitted` row is `duplicate_in_flight`; an + * expired-lease `admitted` row is a compare-and-set `takeover` that renews + * the lease. + */ +export async function admitOperation( + database: LedgerDatabase, + input: AdmitOperationInput +): Promise { + return database.transaction(async tx => admitOperationInTransaction(tx, input)); +} + +async function admitOperationInTransaction( + tx: LedgerTransaction, + input: AdmitOperationInput +): Promise { + const now = new Date(); + + const existing = await tx + .select() + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.kilo_user_id, input.userId), + eq(operation_ledgers.domain, input.domain), + eq(operation_ledgers.operation_key, input.operationKey) + ) + ) + .for('update') + .limit(1); + + if (existing.length === 0) { + // Conflict-safe insert: the unique index on + // (kilo_user_id, domain, operation_key) arbitrates the race. When a + // concurrent admit wins, this insert is a silent no-op instead of raising + // a unique violation. A raised violation would abort the whole transaction + // (PostgreSQL rejects every later statement with 25P02), so the winner + // must never be read after a failure. + const [row] = await tx + .insert(operation_ledgers) + .values(admissionInsertValues(input, now)) + .onConflictDoNothing({ + target: [ + operation_ledgers.kilo_user_id, + operation_ledgers.domain, + operation_ledgers.operation_key, + ], + }) + .returning(); + + if (row) { + return { admission: 'admitted', row }; + } + + // A concurrent admit won the insert race under the same identity key. + // Read the committed winner and classify it. + const [winner] = await tx + .select() + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.kilo_user_id, input.userId), + eq(operation_ledgers.domain, input.domain), + eq(operation_ledgers.operation_key, input.operationKey) + ) + ) + .for('update') + .limit(1); + if (!winner) { + throw new Error('Operation ledger row vanished after a conflict-safe insert'); + } + return evaluateExistingRow(tx, winner, input, now); + } + + return evaluateExistingRow(tx, existing[0], input, now); +} + +async function evaluateExistingRow( + tx: LedgerTransaction, + row: OperationLedgerRow, + input: AdmitOperationInput, + now: Date +): Promise { + // Expired row (any status): delete + fresh insert in one transaction. + if (new Date(row.expires_at).getTime() < now.getTime()) { + await tx.delete(operation_ledgers).where(eq(operation_ledgers.id, row.id)); + const [fresh] = await tx + .insert(operation_ledgers) + .values(admissionInsertValues(input, now)) + .returning(); + if (!fresh) { + throw new Error('Operation ledger re-insert returned no row'); + } + return { admission: 'admitted', row: fresh }; + } + + if (row.status === 'admitted') { + if (new Date(row.lease_expires_at).getTime() >= now.getTime()) { + return { admission: 'duplicate_in_flight', row }; + } + // Compare-and-set lease takeover: renew only while the lease is still expired. + const renewedLease = new Date(now.getTime() + input.leaseSeconds * 1000).toISOString(); + const [renewed] = await tx + .update(operation_ledgers) + .set({ lease_expires_at: renewedLease }) + .where( + and( + eq(operation_ledgers.id, row.id), + eq(operation_ledgers.status, 'admitted'), + sql`${operation_ledgers.lease_expires_at} < ${now.toISOString()}::timestamptz` + ) + ) + .returning(); + return { admission: 'takeover', row: renewed ?? row }; + } + + if (row.status === 'reconcile_pending') { + return { admission: 'duplicate_reconcile_pending', row }; + } + + return { admission: 'duplicate_settled', row }; +} + +// ----- progress and provider reference ------------------------------------------ + +/** + * Merges allocated identifiers into `canonical_result` while the row stays + * `admitted`. Returns the updated row, or null when the row is missing or no + * longer admitted (the CAS did not match). The merged result is bounded at + * `MAX_CANONICAL_RESULT_BYTES` serialized bytes: an oversized merge throws + * `CanonicalResultTooLargeError` and leaves the row unchanged. + */ +export async function recordOperationProgress( + database: LedgerDatabase, + rowId: string, + partialResult: Record +): Promise { + return runInTransaction(database, async tx => { + const [row] = await tx + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.id, rowId)) + .for('update'); + + if (!row || row.status !== 'admitted') { + return null; + } + + const merged = { ...(row.canonical_result ?? {}), ...partialResult }; + const serialized = JSON.stringify(merged) ?? '{}'; + const bytes = serializedByteLength(serialized); + if (bytes > MAX_CANONICAL_RESULT_BYTES) { + throw new CanonicalResultTooLargeError(bytes); + } + + const [updated] = await tx + .update(operation_ledgers) + .set({ canonical_result: merged }) + .where(and(eq(operation_ledgers.id, row.id), eq(operation_ledgers.status, 'admitted'))) + .returning(); + return updated ?? null; + }); +} + +/** + * Overwrites the `provider_ref` column (for example the security provider's + * `messageId`). Used after acceptance and on takeover re-submits so the + * worker can join terminal outcomes by provider reference. + */ +export async function setOperationProviderRef( + database: LedgerDatabase, + input: { rowId: string; providerRef: string | null } +): Promise { + const [updated] = await database + .update(operation_ledgers) + .set({ provider_ref: input.providerRef }) + .where(eq(operation_ledgers.id, input.rowId)) + .returning(); + return updated ?? null; +} + +// ----- terminal settle and reconcile ---------------------------------------------- + +/** + * Settles a row to a terminal status, CAS from `admitted | reconcile_pending`. + * A second settle is a no-op returning the stored row. When `outboxEvent` is + * given, the outbox row is written in the same transaction: settle-plus-outbox + * is atomic — any outbox failure rolls back the settle. The merged + * `canonical_result` must fit `MAX_CANONICAL_RESULT_BYTES` serialized bytes. + */ +export async function settleOperation( + database: LedgerDatabase, + input: SettleOperationInput +): Promise { + return runInTransaction(database, async tx => settleOperationInTransaction(tx, input)); +} + +async function settleOperationInTransaction( + tx: LedgerTransaction, + input: SettleOperationInput +): Promise { + const [row] = await tx + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.id, input.rowId)) + .for('update'); + + if (!row) { + return { settled: false, row: null }; + } + + if (isTerminalOperationStatus(row.status)) { + // Double settle is a no-op. + return { settled: false, row }; + } + + const mergedCanonical = { ...(row.canonical_result ?? {}), ...(input.canonicalResult ?? {}) }; + const serialized = JSON.stringify(mergedCanonical) ?? '{}'; + if (serializedByteLength(serialized) > MAX_CANONICAL_RESULT_BYTES) { + throw new CanonicalResultTooLargeError(serializedByteLength(serialized)); + } + + if (input.outboxEvent) { + validateOutboxEvent(input.outboxEvent); + } + + const [updated] = await tx + .update(operation_ledgers) + .set({ + status: input.status, + outcome_code: input.outcomeCode ?? null, + canonical_result: mergedCanonical, + settled_at: new Date().toISOString(), + }) + .where( + and( + eq(operation_ledgers.id, row.id), + inArray(operation_ledgers.status, OPERATION_NON_TERMINAL_STATUSES) + ) + ) + .returning(); + + if (!updated) { + // Defensive: another writer settled between the lock and the update. + const [current] = await tx + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.id, row.id)); + return { settled: false, row: current ?? null }; + } + + if (input.outboxEvent) { + await insertOutboxEvent(tx, { rowId: row.id, event: input.outboxEvent }); + } + + return { settled: true, row: updated }; +} + +/** + * Marks a row `reconcile_pending`, CAS from `admitted`. May emit an + * `outcome: 'ambiguous'` outbox event (a ledger state change, not an HTTP + * receipt). Returns the stored row when the CAS did not match (missing or not + * `admitted`). + */ +export async function markReconcilePending( + database: LedgerDatabase, + input: MarkReconcilePendingInput +): Promise { + return runInTransaction(database, async tx => { + const [row] = await tx + .select() + .from(operation_ledgers) + .where(eq(operation_ledgers.id, input.rowId)) + .for('update'); + + if (!row) { + return null; + } + if (row.status !== 'admitted') { + return row; + } + + if (input.outboxEvent) { + validateOutboxEvent(input.outboxEvent); + await insertOutboxEvent(tx, { rowId: row.id, event: input.outboxEvent }); + } + + const [updated] = await tx + .update(operation_ledgers) + .set({ status: 'reconcile_pending' }) + .where(and(eq(operation_ledgers.id, row.id), eq(operation_ledgers.status, 'admitted'))) + .returning(); + return updated ?? row; + }); +} + +// ----- outbox insert (the only insert path for analytics_event_outbox) ------------- + +function validateOutboxEvent(event: OutboxEventInput): void { + const schema = ANALYTICS_EVENT_SCHEMAS[event.eventName]; + if (!schema) { + throw new OutboxEventValidationError( + `No catalog schema for analytics event ${event.eventName}` + ); + } + const result = schema.safeParse(event.properties); + if (!result.success) { + throw new OutboxEventValidationError( + `Analytics event ${event.eventName} failed schema validation: ${result.error.message}` + ); + } +} + +/** + * Inserts an outbox row with the deterministic UUIDv5 `event_uuid`. A + * conflicting `event_uuid` (the same row already emitted this event name) is + * skipped: one event per (ledger row, event name) by design. + */ +async function insertOutboxEvent( + tx: LedgerTransaction, + params: { rowId: string; event: OutboxEventInput } +): Promise { + validateOutboxEvent(params.event); + const eventUuid = await computeEventUuid(params.rowId, params.event.eventName); + + const values: NewAnalyticsEventOutboxRow = { + event_uuid: eventUuid, + event_name: params.event.eventName, + distinct_id: params.event.distinctId, + properties: params.event.properties, + status: 'pending', + attempts: 0, + }; + + const [inserted] = await tx + .insert(analytics_event_outbox) + .values(values) + .onConflictDoNothing({ target: analytics_event_outbox.event_uuid }) + .returning(); + return inserted ?? null; +} + +// ----- deterministic UUIDv5 --------------------------------------------------------- + +/** + * Computes the deterministic outbox `event_uuid` for a ledger row and event + * name: UUIDv5 of the UTF-8 string `${rowId}:${eventName}` under + * `EVENT_UUID_NAMESPACE`. + */ +export async function computeEventUuid(rowId: string, eventName: string): Promise { + return uuidv5(EVENT_UUID_NAMESPACE, `${rowId}:${eventName}`); +} + +/** + * UUIDv5 (SHA-1 name-based) computed with WebCrypto. Implemented here because + * no `uuid` package is present in the lockfile. Deterministic across Node and + * Workers runtimes. + */ +export async function uuidv5(namespace: string, name: string): Promise { + const namespaceBytes = parseUuidHex(namespace); + const nameBytes = new TextEncoder().encode(name); + const data = new Uint8Array(namespaceBytes.length + nameBytes.length); + data.set(namespaceBytes, 0); + data.set(nameBytes, namespaceBytes.length); + + const digest = await crypto.subtle.digest('SHA-1', data); + const bytes = new Uint8Array(digest).slice(0, 16); + // Version 5: set the version nibble to 0101. + bytes[6] = (bytes[6] & 0x0f) | 0x50; + // RFC 4122 variant: set the two MSBs of byte 8 to 10. + bytes[8] = (bytes[8] & 0x3f) | 0x80; + return formatUuidHex(bytes); +} + +function parseUuidHex(value: string): Uint8Array { + const hex = value.replace(/-/g, ''); + const bytes = new Uint8Array(16); + for (let index = 0; index < 16; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +const HEX_DIGITS = '0123456789abcdef'; + +function formatUuidHex(bytes: Uint8Array): string { + const hex = Array.from(bytes, byte => HEX_DIGITS[byte >> 4] + HEX_DIGITS[byte & 0x0f]).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function serializedByteLength(value: string): number { + return new TextEncoder().encode(value).length; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2835eb744e..8370d9de92 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -10012,4 +10012,96 @@ export const native_attested_keys = pgTable( ); export type NativeAttestedKey = typeof native_attested_keys.$inferSelect; + +// ── W3-B operation ledger and analytics outbox (P1-A-08a / P2-A-04) ────── + +/** + * Durable per-intent operation ledger (DEC-01). + * + * One row per (kilo_user_id, domain, operation_key) identity. Rows move from + * `admitted` to exactly one terminal state (`completed | failed | no_op | + * interrupted | superseded`) or to the intermediate `reconcile_pending`. + * `canonical_result` carries replay-safe outcome data bounded at 4096 + * serialized bytes by the settle helper. Rows expire 30 days after + * `admitted_at`; an expired row is deleted and re-admitted by the next admit. + */ +export const operation_ledgers = pgTable( + 'operation_ledgers', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + operation_key: text().notNull(), + domain: text().notNull(), + intent: text().notNull(), + kilo_user_id: text().notNull(), + organization_id: text(), + resource_key: text(), + provider_ref: text(), + taxonomy: text().notNull(), + status: text().notNull().default('admitted'), + outcome_code: text(), + canonical_result: jsonb().$type | null>(), + admitted_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + settled_at: timestamp({ withTimezone: true, mode: 'string' }), + lease_expires_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + expires_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + }, + table => [ + uniqueIndex('UQ_operation_ledgers_kilo_user_id_domain_operation_key').on( + table.kilo_user_id, + table.domain, + table.operation_key + ), + index('IDX_operation_ledgers_status_expires_at').on(table.status, table.expires_at), + index('IDX_operation_ledgers_provider_ref').on(table.provider_ref), + ] +); + +export type OperationLedgerRow = typeof operation_ledgers.$inferSelect; +export type NewOperationLedgerRow = typeof operation_ledgers.$inferInsert; + +/** + * Durable analytics outbox (P2-A-04), modeled on `user_affiliate_events`. + * + * Rows are inserted only by the operation-ledger settle helpers in + * `packages/db/src/operation-ledger.ts` (grep-enforced invariant). Delivery + * is at-least-once with a deterministic UUIDv5 `event_uuid`; the drainer + * claims due `pending` rows, sends to PostHog, and marks `delivered`; on + * error it backs off and retries, and fails a row after 8 attempts. `sending` + * claims older than 5 minutes are reclaimed by the cron. Delivered rows purge + * after 7 days, failed rows after 30 days. + */ +export const analytics_event_outbox = pgTable( + 'analytics_event_outbox', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + event_uuid: uuid().notNull(), + event_name: text().notNull(), + distinct_id: text().notNull(), + properties: jsonb().$type>().notNull(), + status: text().notNull().default('pending'), + attempts: integer().notNull().default(0), + next_attempt_at: timestamp({ withTimezone: true, mode: 'string' }), + claimed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + delivered_at: timestamp({ withTimezone: true, mode: 'string' }), + last_error: text(), + }, + table => [ + uniqueIndex('UQ_analytics_event_outbox_event_uuid').on(table.event_uuid), + index('IDX_analytics_event_outbox_status_next_attempt_at').on( + table.status, + table.next_attempt_at + ), + ] +); + +export type AnalyticsEventOutboxRow = typeof analytics_event_outbox.$inferSelect; +export type NewAnalyticsEventOutboxRow = typeof analytics_event_outbox.$inferInsert; + export type NewContainerUsageSegment = typeof container_usage_segment.$inferInsert; From 82f89a4335cc58ce0effdf88aef7363001b6b909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 02:20:46 +0200 Subject: [PATCH 03/56] feat(analytics): add outbox dispatch cron --- .../dispatch-analytics-outbox/route.test.ts | 73 +++++ .../cron/dispatch-analytics-outbox/route.ts | 35 +++ .../src/lib/analytics-outbox/dispatch.test.ts | 258 ++++++++++++++++++ apps/web/src/lib/analytics-outbox/dispatch.ts | 202 ++++++++++++++ apps/web/vercel.json | 4 + 5 files changed, 572 insertions(+) create mode 100644 apps/web/src/app/api/cron/dispatch-analytics-outbox/route.test.ts create mode 100644 apps/web/src/app/api/cron/dispatch-analytics-outbox/route.ts create mode 100644 apps/web/src/lib/analytics-outbox/dispatch.test.ts create mode 100644 apps/web/src/lib/analytics-outbox/dispatch.ts diff --git a/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.test.ts b/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.test.ts new file mode 100644 index 0000000000..eb117b2c13 --- /dev/null +++ b/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.test.ts @@ -0,0 +1,73 @@ +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/config.server', () => ({ + CRON_SECRET: 'cron-secret', +})); + +jest.mock('@/lib/analytics-outbox/dispatch', () => ({ + dispatchQueuedAnalyticsEvents: jest.fn(), +})); + +import { dispatchQueuedAnalyticsEvents } from '@/lib/analytics-outbox/dispatch'; +import { GET } from './route'; + +const mockDispatchQueuedAnalyticsEvents = jest.mocked(dispatchQueuedAnalyticsEvents); + +describe('GET /api/cron/dispatch-analytics-outbox', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects unauthorized requests', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/cron/dispatch-analytics-outbox', { + method: 'GET', + }) + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + expect(mockDispatchQueuedAnalyticsEvents).not.toHaveBeenCalled(); + }); + + it('dispatches queued analytics events when authorized', async () => { + mockDispatchQueuedAnalyticsEvents.mockResolvedValue({ + reclaimed: 1, + claimed: 3, + delivered: 2, + retried: 1, + failed: 0, + outboxDeliveredPurged: 1, + outboxFailedPurged: 0, + expiredUnsettledLedgerSettled: 1, + }); + + const response = await GET( + new NextRequest('http://localhost:3000/api/cron/dispatch-analytics-outbox', { + method: 'GET', + headers: { + authorization: 'Bearer cron-secret', + }, + }) + ); + + expect(response.status).toBe(200); + expect(mockDispatchQueuedAnalyticsEvents).toHaveBeenCalledTimes(1); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + success: true, + summary: { + reclaimed: 1, + claimed: 3, + delivered: 2, + retried: 1, + failed: 0, + outboxDeliveredPurged: 1, + outboxFailedPurged: 0, + expiredUnsettledLedgerSettled: 1, + }, + timestamp: expect.any(String), + }) + ); + }); +}); diff --git a/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.ts b/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.ts new file mode 100644 index 0000000000..6ef4daef80 --- /dev/null +++ b/apps/web/src/app/api/cron/dispatch-analytics-outbox/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +import { CRON_SECRET } from '@/lib/config.server'; +import { dispatchQueuedAnalyticsEvents } from '@/lib/analytics-outbox/dispatch'; +import { sentryLogger } from '@/lib/utils.server'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + const expectedAuth = `Bearer ${CRON_SECRET}`; + if (authHeader !== expectedAuth) { + sentryLogger( + 'cron', + 'warning' + )( + 'SECURITY: Invalid CRON job authorization attempt: ' + + (authHeader ? 'Invalid authorization header' : 'Missing authorization header') + ); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const summary = await dispatchQueuedAnalyticsEvents(); + + return NextResponse.json( + { + success: true, + summary, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); +} diff --git a/apps/web/src/lib/analytics-outbox/dispatch.test.ts b/apps/web/src/lib/analytics-outbox/dispatch.test.ts new file mode 100644 index 0000000000..7a8656cfe2 --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/dispatch.test.ts @@ -0,0 +1,258 @@ +/** + * Unit tests for the analytics outbox cron drainer (P2-A-04): reclaim, claim, + * send, delivered/retry/fail, purge, and the expired-unsettled ledger + * backstop, in order, with the PostHog client stubbed. The DB state-machine + * transitions themselves are covered by the integration suite in this + * directory; here the dispatcher orchestration and its summary accounting are + * the unit under test. The database argument is the mocked `@/lib/drizzle` + * instance; the option objects are the behavior under test. + */ +import { randomUUID } from 'crypto'; + +import { dispatchQueuedAnalyticsEvents } from '@/lib/analytics-outbox/dispatch'; +import { + claimDueOutboxEvents, + markOutboxDelivered, + markOutboxRetry, + purgeExpired, + reclaimStaleSendingEvents, +} from '@kilocode/db/analytics-outbox'; +import type { AnalyticsEventOutboxRow } from '@kilocode/db/schema'; + +const mockCapture = jest.fn(); + +jest.mock('@/lib/posthog', () => ({ + __esModule: true, + default: jest.fn(() => ({ capture: mockCapture })), +})); + +jest.mock('@/lib/drizzle', () => ({ + db: {}, +})); + +jest.mock('@/lib/config.server', () => ({ + IS_IN_AUTOMATED_TEST: true, +})); + +jest.mock('@sentry/nextjs', () => ({ + captureMessage: jest.fn(), + captureException: jest.fn(), +})); + +jest.mock('@kilocode/db/analytics-outbox', () => ({ + claimDueOutboxEvents: jest.fn(), + markOutboxDelivered: jest.fn(), + markOutboxRetry: jest.fn(), + purgeExpired: jest.fn(), + reclaimStaleSendingEvents: jest.fn(), +})); + +const mockClaimDueOutboxEvents = jest.mocked(claimDueOutboxEvents); +const mockMarkOutboxDelivered = jest.mocked(markOutboxDelivered); +const mockMarkOutboxRetry = jest.mocked(markOutboxRetry); +const mockPurgeExpired = jest.mocked(purgeExpired); +const mockReclaimStaleSendingEvents = jest.mocked(reclaimStaleSendingEvents); + +function makeRow(overrides: Partial = {}): AnalyticsEventOutboxRow { + return { + id: randomUUID(), + event_uuid: randomUUID(), + event_name: 'session_create_settled', + distinct_id: 'user@example.com', + properties: { source: 'server' }, + status: 'sending', + attempts: 0, + next_attempt_at: null, + claimed_at: new Date().toISOString(), + created_at: new Date().toISOString(), + delivered_at: null, + last_error: null, + ...overrides, + }; +} + +const emptyPurge = { + outboxDeliveredPurged: 0, + outboxFailedPurged: 0, + expiredUnsettledLedgerSettled: 0, +}; + +describe('dispatchQueuedAnalyticsEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCapture.mockReset(); + mockReclaimStaleSendingEvents.mockResolvedValue([]); + mockClaimDueOutboxEvents.mockResolvedValue([]); + mockPurgeExpired.mockResolvedValue(emptyPurge); + }); + + it('claims and delivers a due event with the deterministic event_uuid', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockMarkOutboxDelivered.mockResolvedValue(row); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: row.distinct_id, + event: row.event_name, + properties: row.properties, + uuid: row.event_uuid, + }); + expect(mockMarkOutboxDelivered).toHaveBeenCalledWith(expect.anything(), { + eventId: row.id, + claimedAt: row.claimed_at, + }); + expect(summary).toEqual({ + reclaimed: 0, + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + outboxDeliveredPurged: 0, + outboxFailedPurged: 0, + expiredUnsettledLedgerSettled: 0, + }); + }); + + it('walks reclaim, claim, delivery, and purge in the required order', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockMarkOutboxDelivered.mockResolvedValue(row); + + await dispatchQueuedAnalyticsEvents(); + + const reclaimOrder = mockReclaimStaleSendingEvents.mock.invocationCallOrder[0]; + const claimOrder = mockClaimDueOutboxEvents.mock.invocationCallOrder[0]; + const markOrder = mockMarkOutboxDelivered.mock.invocationCallOrder[0]; + const purgeOrder = mockPurgeExpired.mock.invocationCallOrder[0]; + expect(reclaimOrder).toBeLessThan(claimOrder); + expect(claimOrder).toBeLessThan(markOrder); + expect(markOrder).toBeLessThan(purgeOrder); + }); + + it('backs a failed send off for retry with the error recorded', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockCapture.mockImplementation(() => { + throw new Error('posthog unavailable'); + }); + mockMarkOutboxRetry.mockResolvedValue({ + outcome: 'retried', + row: { ...row, status: 'pending', attempts: 1 }, + }); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockMarkOutboxRetry).toHaveBeenCalledWith(expect.anything(), { + eventId: row.id, + claimedAt: row.claimed_at, + error: 'posthog unavailable', + }); + expect(mockMarkOutboxDelivered).not.toHaveBeenCalled(); + expect(summary.claimed).toBe(1); + expect(summary.retried).toBe(1); + expect(summary.failed).toBe(0); + expect(summary.delivered).toBe(0); + }); + + it('fails a claimed event terminally when the retry cap is reached', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockCapture.mockImplementation(() => { + throw new Error('still down'); + }); + mockMarkOutboxRetry.mockResolvedValue({ + outcome: 'failed', + row: { ...row, status: 'failed', attempts: 8 }, + }); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockMarkOutboxRetry).toHaveBeenCalledWith(expect.anything(), { + eventId: row.id, + claimedAt: row.claimed_at, + error: 'still down', + }); + expect(summary.failed).toBe(1); + expect(summary.retried).toBe(0); + }); + + it('counts reclaimed stale claims before claiming', async () => { + mockReclaimStaleSendingEvents.mockResolvedValue([makeRow(), makeRow()]); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(summary.reclaimed).toBe(2); + expect(summary.claimed).toBe(0); + expect(mockReclaimStaleSendingEvents.mock.invocationCallOrder[0]).toBeLessThan( + mockPurgeExpired.mock.invocationCallOrder[0] + ); + }); + + it('surfaces the retention purge and ledger backstop counts', async () => { + mockPurgeExpired.mockResolvedValue({ + outboxDeliveredPurged: 3, + outboxFailedPurged: 1, + expiredUnsettledLedgerSettled: 2, + }); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockPurgeExpired).toHaveBeenCalledTimes(1); + expect(summary.outboxDeliveredPurged).toBe(3); + expect(summary.outboxFailedPurged).toBe(1); + expect(summary.expiredUnsettledLedgerSettled).toBe(2); + }); + + it('claims in bounded batches until no rows are due', async () => { + const first = makeRow(); + const second = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([first]).mockResolvedValueOnce([second]); + mockMarkOutboxDelivered.mockResolvedValue(first); + + const summary = await dispatchQueuedAnalyticsEvents({ limit: 2 }); + + expect(mockClaimDueOutboxEvents.mock.calls.map(call => call[1])).toEqual([2, 1]); + expect(mockMarkOutboxDelivered).toHaveBeenCalledTimes(2); + expect(summary.claimed).toBe(2); + expect(summary.delivered).toBe(2); + }); + + it('counts a late delivery mark from a reclaimed claim as delivered', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockMarkOutboxDelivered.mockResolvedValue(null); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(summary.delivered).toBe(1); + }); + + it('counts a lost claim on retry as retried', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockCapture.mockImplementation(() => { + throw new Error('posthog unavailable'); + }); + mockMarkOutboxRetry.mockResolvedValue(null); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(summary.retried).toBe(1); + expect(summary.failed).toBe(0); + }); + + it('stops claiming once the batch limit is exhausted', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockMarkOutboxDelivered.mockResolvedValue(row); + + await dispatchQueuedAnalyticsEvents({ limit: 1 }); + + expect(mockClaimDueOutboxEvents).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/analytics-outbox/dispatch.ts b/apps/web/src/lib/analytics-outbox/dispatch.ts new file mode 100644 index 0000000000..c2bcfa0ef2 --- /dev/null +++ b/apps/web/src/lib/analytics-outbox/dispatch.ts @@ -0,0 +1,202 @@ +/** + * Cron drainer for the durable analytics outbox (P2-A-04). + * + * Walks the delivery state machine in one pass, in this order: reclaim, claim, + * send, delivered/retry/fail, purge, and the expired-unsettled ledger + * backstop. Row transitions themselves live in `@kilocode/db/analytics-outbox`; + * this module only orchestrates them and performs the PostHog send. + * + * Delivery goes through `PostHogClient()`. Outside production that client is a + * no-op, so rows still advance to `delivered`; the state machine is + * environment-independent. Each send passes the deterministic `event_uuid` as + * the PostHog event UUID (`posthog-node` 5.10.4 supports `EventMessage.uuid`), + * which PostHog uses to dedupe replayed at-least-once deliveries. A + * synchronous capture throw is a failed send and drives the DB-side backoff + * retry or terminal failure. + */ +import 'server-only'; + +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import PostHogClient from '@/lib/posthog'; +import { sentryLogger } from '@/lib/utils.server'; +import { + claimDueOutboxEvents, + markOutboxDelivered, + markOutboxRetry, + purgeExpired, + reclaimStaleSendingEvents, +} from '@kilocode/db/analytics-outbox'; +import type { AnalyticsEventOutboxRow } from '@kilocode/db/schema'; + +const logInfo = sentryLogger('analytics-outbox', 'info'); +const logWarning = sentryLogger('analytics-outbox', 'warning'); +const logError = sentryLogger('analytics-outbox', 'error'); + +const DEFAULT_CLAIM_LIMIT = 100; + +type DatabaseClient = typeof db | DrizzleTransaction; + +export type AnalyticsOutboxDispatchSummary = { + reclaimed: number; + claimed: number; + delivered: number; + retried: number; + failed: number; + outboxDeliveredPurged: number; + outboxFailedPurged: number; + expiredUnsettledLedgerSettled: number; +}; + +type OutboxDispatchOutcome = 'delivered' | 'retried' | 'failed'; + +/** + * Drains the analytics outbox in one cron pass: reclaims stale `sending` + * claims, claims due `pending` rows in bounded batches and sends each to + * PostHog, then purges retained rows and settles expired non-terminal ledger + * rows. Returns a per-step summary for the cron route. + */ +export async function dispatchQueuedAnalyticsEvents(params?: { + database?: DatabaseClient; + limit?: number; +}): Promise { + const database = params?.database ?? db; + const limit = params?.limit ?? DEFAULT_CLAIM_LIMIT; + const summary: AnalyticsOutboxDispatchSummary = { + reclaimed: 0, + claimed: 0, + delivered: 0, + retried: 0, + failed: 0, + outboxDeliveredPurged: 0, + outboxFailedPurged: 0, + expiredUnsettledLedgerSettled: 0, + }; + + // Reclaim `sending` claims left behind by crashed drainers. + const reclaimed = await reclaimStaleSendingEvents(database); + summary.reclaimed = reclaimed.length; + for (const row of reclaimed) { + logWarning('Reclaimed stale analytics outbox claim', { + ...outboxLogFields(row), + dispatch_source: 'cron', + }); + } + + // Claim due `pending` rows in bounded batches and send each to PostHog. + let remaining = limit; + while (remaining > 0) { + const claimed = await claimDueOutboxEvents(database, remaining); + if (claimed.length === 0) { + break; + } + summary.claimed += claimed.length; + remaining -= claimed.length; + + for (const row of claimed) { + const outcome = await dispatchOutboxEvent(database, row); + if (outcome === 'delivered') { + summary.delivered += 1; + } else if (outcome === 'retried') { + summary.retried += 1; + } else { + summary.failed += 1; + } + } + } + + // DEC-01 retention purge and the expired-unsettled ledger backstop. + const purge = await purgeExpired(database); + summary.outboxDeliveredPurged = purge.outboxDeliveredPurged; + summary.outboxFailedPurged = purge.outboxFailedPurged; + summary.expiredUnsettledLedgerSettled = purge.expiredUnsettledLedgerSettled; + + return summary; +} + +/** + * Sends one claimed outbox event and drives its delivery mark. Marks are + * claim-fenced on `claimed_at`, so a late mark from a reclaimed claim is a + * no-op that leaves the row to the newer claim. + */ +async function dispatchOutboxEvent( + database: DatabaseClient, + row: AnalyticsEventOutboxRow +): Promise { + const claimedAt = row.claimed_at; + if (!claimedAt) { + // Unreachable through `claimDueOutboxEvents` (it always stamps the claim); + // guard so a malformed row cannot be sent without a fence token. + logError('Analytics outbox row claimed without a claim token', { + analytics_event_id: row.id, + analytics_event_name: row.event_name, + dispatch_source: 'cron', + }); + return 'failed'; + } + + try { + sendToPostHog(row); + } catch (error) { + const message = errorMessage(error); + logError('Analytics outbox send failed', { + ...outboxLogFields(row), + error: message, + dispatch_source: 'cron', + }); + const result = await markOutboxRetry(database, { + eventId: row.id, + claimedAt, + error: message, + }); + if (!result) { + // A stale sender: the claim was reclaimed or the row is already terminal. + return 'retried'; + } + return result.outcome === 'failed' ? 'failed' : 'retried'; + } + + const delivered = await markOutboxDelivered(database, { eventId: row.id, claimedAt }); + if (!delivered) { + // The claim was reclaimed and re-claimed mid-flight; the event was sent + // and the newer claim owns the row now. + logWarning('Analytics outbox delivery mark skipped: claim already transitioned', { + ...outboxLogFields(row), + dispatch_source: 'cron', + }); + return 'delivered'; + } + logInfo('Delivered analytics outbox event', { + ...outboxLogFields(delivered), + dispatch_source: 'cron', + }); + return 'delivered'; +} + +/** + * Sends one event to PostHog. The deterministic `event_uuid` goes in the + * PostHog event UUID field; if the installed client ever dropped that field, + * the catalog fallback carries it as an `event_uuid` property instead. + */ +function sendToPostHog(row: AnalyticsEventOutboxRow): void { + PostHogClient().capture({ + distinctId: row.distinct_id, + event: row.event_name, + properties: row.properties, + uuid: row.event_uuid, + }); +} + +function outboxLogFields(row: AnalyticsEventOutboxRow): Record { + return { + analytics_event_id: row.id, + analytics_event_uuid: row.event_uuid, + analytics_event_name: row.event_name, + distinct_id: row.distinct_id, + status: row.status, + attempts: row.attempts, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 43e6e0f6de..84bb0f466f 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -76,6 +76,10 @@ "path": "/api/cron/dispatch-affiliate-events", "schedule": "* * * * *" }, + { + "path": "/api/cron/dispatch-analytics-outbox", + "schedule": "* * * * *" + }, { "path": "/api/cron/dispatch-pending-code-reviews", "schedule": "*/10 * * * *" From 5368a601fe4fb5a1aa323c2fe995acddae34906f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 02:20:52 +0200 Subject: [PATCH 04/56] feat(mobile): track settled leaf screens --- apps/mobile/src/app/_layout.tsx | 6 +- .../hooks/screen-tracking-decision.test.ts | 166 ++++++++ .../src/lib/hooks/screen-tracking-decision.ts | 105 +++++ .../src/lib/hooks/use-screen-tracking.test.ts | 388 ++++++++++++++++++ .../src/lib/hooks/use-screen-tracking.ts | 167 +++++++- apps/mobile/src/lib/route-lifecycle.ts | 44 ++ 6 files changed, 864 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/screen-tracking-decision.test.ts create mode 100644 apps/mobile/src/lib/hooks/screen-tracking-decision.ts create mode 100644 apps/mobile/src/lib/hooks/use-screen-tracking.test.ts create mode 100644 apps/mobile/src/lib/route-lifecycle.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 64541de1f5..692ac18988 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -302,7 +302,11 @@ function RootLayoutNav() { accountId: userId, optionalConsent, }); - useScreenTracking(); + // Consent is settled when the account and its consent decision have loaded + // without error and no consent prompt is outstanding. Screen capture must + // wait for this: analytics eligibility is decided only after consent. + const bootstrapSettled = token != null && consentChecked && !needsConsent && !consentCheckError; + useScreenTracking(bootstrapSettled); useEffect(() => { if (shareIntentError) { diff --git a/apps/mobile/src/lib/hooks/screen-tracking-decision.test.ts b/apps/mobile/src/lib/hooks/screen-tracking-decision.test.ts new file mode 100644 index 0000000000..a676e1e05b --- /dev/null +++ b/apps/mobile/src/lib/hooks/screen-tracking-decision.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { decideScreenTracking, type ScreenTrackingDecision } from './screen-tracking-decision'; + +function decision( + overrides: Partial[0]> = {} +): ScreenTrackingDecision { + return decideScreenTracking({ + segments: ['(app)', '(tabs)', '(0_home)'], + settled: true, + analyticsReady: true, + captureAccepted: true, + bootstrapSettled: true, + accountGeneration: 1, + lastCaptured: null, + ...overrides, + }); +} + +describe('decideScreenTracking', () => { + it('captures the settled leaf when all gates pass', () => { + expect(decision()).toEqual({ + capture: true, + screenName: '(app)/(tabs)/(0_home)', + reason: 'captured', + }); + }); + + it('keeps bracket placeholders in the captured screen name', () => { + const result = decision({ + segments: ['(app)', 'agent-chat', '[session-id]'], + }); + expect(result).toEqual({ + capture: true, + screenName: '(app)/agent-chat/[session-id]', + reason: 'captured', + }); + }); + + it('never captures while the route is not settled', () => { + expect(decision({ settled: false })).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(0_home)', + reason: 'not-settled', + }); + }); + + it('never captures while analytics is not ready', () => { + expect(decision({ analyticsReady: false })).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(0_home)', + reason: 'analytics-not-ready', + }); + }); + + it('never captures when the ready client belongs to an older generation', () => { + expect(decision({ captureAccepted: false })).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(0_home)', + reason: 'analytics-client-stale', + }); + }); + + it('never captures while the consent bootstrap is not settled', () => { + expect(decision({ bootstrapSettled: false })).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(0_home)', + reason: 'bootstrap-not-settled', + }); + }); + + it('never captures when there are no segments', () => { + expect(decision({ segments: [] })).toEqual({ + capture: false, + screenName: undefined, + reason: 'no-screen', + }); + }); + + it('never captures the redirect-only (app)/index route', () => { + expect(decision({ segments: ['(app)', 'index'] })).toEqual({ + capture: false, + screenName: '(app)/index', + reason: 'redirect-only', + }); + }); + + it('never captures the redirect-only (app) production representation', () => { + // Expo Router strips a trailing `index`, so `(app)/index` appears as + // `['(app)']` through `useSegments()` in production. + expect(decision({ segments: ['(app)'] })).toEqual({ + capture: false, + screenName: '(app)', + reason: 'redirect-only', + }); + }); + + it('captures real (app) leaves that are not the redirect target', () => { + expect(decision({ segments: ['(app)', 'onboarding'] })).toEqual({ + capture: true, + screenName: '(app)/onboarding', + reason: 'captured', + }); + }); + + it('never captures the KiloClaw tab group', () => { + expect(decision({ segments: ['(app)', '(tabs)', '(1_kiloclaw)'] })).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(1_kiloclaw)', + reason: 'kiloclaw-excluded', + }); + }); + + it('never captures kiloclaw settings routes', () => { + const result = decision({ segments: ['(app)', 'kiloclaw', '[instance-id]', 'settings'] }); + expect(result).toEqual({ + capture: false, + screenName: '(app)/kiloclaw/[instance-id]/settings', + reason: 'kiloclaw-excluded', + }); + }); + + it('drops a duplicate capture of the same screen in the same generation', () => { + expect( + decision({ + lastCaptured: { generation: 1, screenName: '(app)/(tabs)/(0_home)' }, + }) + ).toEqual({ + capture: false, + screenName: '(app)/(tabs)/(0_home)', + reason: 'duplicate', + }); + }); + + it('captures a different screen in the same generation', () => { + expect( + decision({ + segments: ['(app)', '(tabs)', '(3_profile)'], + lastCaptured: { generation: 1, screenName: '(app)/(tabs)/(0_home)' }, + }) + ).toEqual({ + capture: true, + screenName: '(app)/(tabs)/(3_profile)', + reason: 'captured', + }); + }); + + it('re-allows the same screen after an account generation change', () => { + expect( + decision({ + accountGeneration: 2, + lastCaptured: { generation: 1, screenName: '(app)/(tabs)/(0_home)' }, + }) + ).toEqual({ + capture: true, + screenName: '(app)/(tabs)/(0_home)', + reason: 'captured', + }); + }); + + it('captures when nothing was captured yet even with a late generation', () => { + expect(decision({ accountGeneration: 5, lastCaptured: null })).toMatchObject({ + capture: true, + }); + }); +}); diff --git a/apps/mobile/src/lib/hooks/screen-tracking-decision.ts b/apps/mobile/src/lib/hooks/screen-tracking-decision.ts new file mode 100644 index 0000000000..36088f3c25 --- /dev/null +++ b/apps/mobile/src/lib/hooks/screen-tracking-decision.ts @@ -0,0 +1,105 @@ +/** + * Pure decision for settled-leaf screen tracking. + * + * Decides whether PostHog should capture a `$screen` event for the current + * route. No React, no side effects: the hook owns the settle timer, the + * PostHog readiness subscription, and the capture call itself. + * + * All of these must hold for a capture: + * 1. The route has settled: navigation is not stale and the segment array + * stayed unchanged for the settle window (computed by the hook). + * 2. Analytics is ready and will accept the capture: the PostHog client + * exists and belongs to the current account generation — a stale client + * silently drops the event, so it must not consume a dedupe slot. + * 3. The consent bootstrap has settled: the account and its consent decision + * have loaded without error. + * 4. The screen is not a redirect-only route (it never renders a real leaf). + * 5. The screen is not a KiloClaw route (the `(1_kiloclaw)` group or any + * `kiloclaw` segment — KiloClaw surfaces are excluded from screen capture). + * 6. The screen was not already captured for the current account generation. + * + * Segment names keep their bracket placeholders (e.g. `chat/[sandbox-id]`), + * so dynamic values never leave the device. + */ + +/** How long segments must stay unchanged before a route counts as settled. */ +export const SCREEN_TRACKING_SETTLE_DEBOUNCE_MS = 500; + +// Redirect-only route files never render a real screen. `(app)/index` only +// redirects to the tabs home, so its screen name is never tracked. Expo +// Router's `getRouteInfoFromState` strips a trailing `index`, so the +// production `useSegments()` representation of that file is `['(app)']` +// (screen name `(app)`). Both exact forms are the same redirect file; other +// `(app)` leaves such as `(app)/onboarding` keep their own names and are +// never excluded. +const REDIRECT_ONLY_SCREENS: ReadonlySet = new Set(['(app)/index', '(app)']); + +export type ScreenTrackingCapture = { + readonly generation: number; + readonly screenName: string; +}; + +export type ScreenTrackingInput = { + readonly segments: readonly string[]; + readonly settled: boolean; + readonly analyticsReady: boolean; + /** The ready PostHog client will accept the capture: it belongs to the + * current account generation and optional telemetry is allowed. A stale + * client silently drops events, so it must never consume a dedupe slot. */ + readonly captureAccepted: boolean; + readonly bootstrapSettled: boolean; + readonly accountGeneration: number; + readonly lastCaptured: ScreenTrackingCapture | null; +}; + +export type ScreenTrackingReason = + | 'not-settled' + | 'analytics-not-ready' + | 'analytics-client-stale' + | 'bootstrap-not-settled' + | 'no-screen' + | 'redirect-only' + | 'kiloclaw-excluded' + | 'duplicate'; + +export type ScreenTrackingDecision = + | { readonly capture: true; readonly screenName: string; readonly reason: 'captured' } + | { + readonly capture: false; + readonly screenName: string | undefined; + readonly reason: ScreenTrackingReason; + }; + +export function decideScreenTracking(input: ScreenTrackingInput): ScreenTrackingDecision { + const screenName = input.segments.length > 0 ? input.segments.join('/') : undefined; + + if (!input.settled) { + return { capture: false, screenName, reason: 'not-settled' }; + } + if (!input.analyticsReady) { + return { capture: false, screenName, reason: 'analytics-not-ready' }; + } + if (!input.captureAccepted) { + return { capture: false, screenName, reason: 'analytics-client-stale' }; + } + if (!input.bootstrapSettled) { + return { capture: false, screenName, reason: 'bootstrap-not-settled' }; + } + if (screenName === undefined) { + return { capture: false, screenName, reason: 'no-screen' }; + } + if (REDIRECT_ONLY_SCREENS.has(screenName)) { + return { capture: false, screenName, reason: 'redirect-only' }; + } + if (input.segments.some(segment => segment.includes('kiloclaw'))) { + return { capture: false, screenName, reason: 'kiloclaw-excluded' }; + } + if ( + input.lastCaptured !== null && + input.lastCaptured.generation === input.accountGeneration && + input.lastCaptured.screenName === screenName + ) { + return { capture: false, screenName, reason: 'duplicate' }; + } + return { capture: true, screenName, reason: 'captured' }; +} diff --git a/apps/mobile/src/lib/hooks/use-screen-tracking.test.ts b/apps/mobile/src/lib/hooks/use-screen-tracking.test.ts new file mode 100644 index 0000000000..b2f0f61fc0 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-screen-tracking.test.ts @@ -0,0 +1,388 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); see src/test/render-with-providers.tsx */ +/* eslint-disable import/first -- mocks must be defined before the module under test is imported */ +import { createElement, type FC } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getSettledLeafRoute, + publishSettledLeafRoute, + resetSettledLeafRouteForTests, + subscribeSettledLeafRoute, +} from '@/lib/route-lifecycle'; +import { SCREEN_TRACKING_SETTLE_DEBOUNCE_MS } from '@/lib/hooks/screen-tracking-decision'; +import { SCREEN_TRACKING_GENERATION_POLL_MS } from '@/lib/hooks/use-screen-tracking'; + +const mocks = vi.hoisted(() => { + const state = { + segments: [] as string[], + stale: true as boolean | undefined, + postHogReady: false, + generation: 1, + }; + const readyListeners = new Set<() => void>(); + const navStateListeners = new Set<() => void>(); + const navigationRef = { + current: { + getRootState: () => (state.stale === undefined ? undefined : { stale: state.stale }), + }, + addListener: (_event: string, listener: () => void): (() => void) => { + navStateListeners.add(listener); + return () => { + navStateListeners.delete(listener); + }; + }, + }; + return { + state, + navigationRef, + captureScreen: vi.fn<(name: string) => void>(), + setPostHogReady(ready: boolean): void { + state.postHogReady = ready; + for (const listener of readyListeners) { + listener(); + } + }, + subscribePostHogReady(listener: () => void): () => void { + readyListeners.add(listener); + return () => { + readyListeners.delete(listener); + }; + }, + emitNavStateChange(): void { + for (const listener of navStateListeners) { + listener(); + } + }, + // Renderers are never unmounted by this harness, so their subscriptions + // persist in the listener sets across tests and would fire the shared + // mocks (and re-capture) when a later test flips readiness or navigation. + clearListeners(): void { + readyListeners.clear(); + navStateListeners.clear(); + }, + }; +}); + +vi.mock('expo-router', () => ({ + useSegments: () => mocks.state.segments, + useNavigationContainerRef: () => mocks.navigationRef, +})); + +vi.mock('@/lib/analytics/posthog', () => ({ + captureScreen: mocks.captureScreen, + isPostHogReady: () => mocks.state.postHogReady, + subscribeToPostHogReady: (listener: () => void) => mocks.subscribePostHogReady(listener), +})); + +vi.mock('@/lib/telemetry/controller', () => ({ + allowsOptional: () => true, + currentGeneration: () => mocks.state.generation, +})); + +import { useScreenTracking } from './use-screen-tracking'; + +const HOME = '(app)/(tabs)/(0_home)'; +const PROFILE = '(app)/(tabs)/(3_profile)'; + +const TestHarness: FC<{ bootstrapSettled: boolean }> = ({ bootstrapSettled }) => { + useScreenTracking(bootstrapSettled); + return null; +}; + +function mount(bootstrapSettled = true): TestRenderer.ReactTestRenderer { + let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + act(() => { + renderer = TestRenderer.create(createElement(TestHarness, { bootstrapSettled })); + }); + // act() is synchronous; renderer is assigned inside the callback. + return renderer as unknown as TestRenderer.ReactTestRenderer; +} + +function rerender(renderer: TestRenderer.ReactTestRenderer, bootstrapSettled = true): void { + act(() => { + renderer.update(createElement(TestHarness, { bootstrapSettled })); + }); +} + +function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms); + }); +} + +function advanceSettleWindow(): void { + advance(SCREEN_TRACKING_SETTLE_DEBOUNCE_MS); +} + +function flipPostHogReady(ready: boolean): void { + act(() => { + mocks.setPostHogReady(ready); + }); +} + +describe('useScreenTracking', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('__DEV__', false); + mocks.state.segments = ['(app)', '(tabs)', '(0_home)']; + mocks.state.stale = false; + mocks.state.postHogReady = true; + mocks.state.generation = 1; + mocks.captureScreen.mockReset(); + resetSettledLeafRouteForTests(); + }); + + afterEach(() => { + mocks.clearListeners(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('captures the settled home leaf once after the settle window', () => { + mount(); + advanceSettleWindow(); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith(HOME); + }); + + it('does not capture before the settle window elapses', () => { + mount(); + advance(SCREEN_TRACKING_SETTLE_DEBOUNCE_MS - 1); + + expect(mocks.captureScreen).not.toHaveBeenCalled(); + }); + + it('never captures a transient route that changes within the settle window', () => { + const renderer = mount(); + advance(300); + mocks.state.segments = ['(app)', '(tabs)', '(3_profile)']; + rerender(renderer); + advanceSettleWindow(); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith(PROFILE); + }); + + it('gives a segment change a full quiet period after a route already settled', () => { + const renderer = mount(); + advanceSettleWindow(); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(getSettledLeafRoute()).toBe(HOME); + + mocks.state.segments = ['(app)', '(tabs)', '(3_profile)']; + rerender(renderer); + // The previous leaf's settled value must not carry over to the new route: + // the new route needs its own full settle window before capture or publish. + advance(SCREEN_TRACKING_SETTLE_DEBOUNCE_MS - 1); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(getSettledLeafRoute()).toBe(HOME); + + advanceSettleWindow(); + expect(mocks.captureScreen).toHaveBeenCalledTimes(2); + expect(mocks.captureScreen).toHaveBeenLastCalledWith(PROFILE); + expect(getSettledLeafRoute()).toBe(PROFILE); + }); + + it('re-evaluates when navigation becomes ready without a manual rerender', () => { + mocks.state.stale = true; + mount(); + advanceSettleWindow(); + expect(mocks.captureScreen).not.toHaveBeenCalled(); + + // The container's `state` event is the reactive signal: flipping the stale + // flag alone must re-evaluate, with no component rerender. + mocks.state.stale = false; + act(() => { + mocks.emitNavStateChange(); + }); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith(HOME); + }); + + it('captures the first settled leaf when PostHog becomes ready late', () => { + mocks.state.postHogReady = false; + mount(); + advanceSettleWindow(); + expect(mocks.captureScreen).not.toHaveBeenCalled(); + // The leaf is settled and visible even though analytics is not ready. + expect(getSettledLeafRoute()).toBe(HOME); + + flipPostHogReady(true); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith(HOME); + }); + + it('does not re-capture a stable leaf when readiness re-evaluates', () => { + mount(); + advanceSettleWindow(); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + + flipPostHogReady(false); + flipPostHogReady(true); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + }); + + it('gives a previously settled route a fresh quiet period when revisited within the window', () => { + const renderer = mount(); + advanceSettleWindow(); + expect(getSettledLeafRoute()).toBe(HOME); + + // Reset the signal so a premature publish on the revisit is observable. + resetSettledLeafRouteForTests(); + + // Leave HOME; the new route has not settled yet. + mocks.state.segments = ['(app)', '(tabs)', '(3_profile)']; + rerender(renderer); + advance(300); + + // Return to HOME before any fresh quiet period has elapsed. HOME's old + // settled marker must not count again: it needs its own full window before + // it can publish or capture. + mocks.state.segments = ['(app)', '(tabs)', '(0_home)']; + rerender(renderer); + advance(SCREEN_TRACKING_SETTLE_DEBOUNCE_MS - 1); + expect(getSettledLeafRoute()).toBeNull(); + + advanceSettleWindow(); + expect(getSettledLeafRoute()).toBe(HOME); + // Same generation and screen: the capture is a duplicate. + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + }); + + it('does not consume the new generation dedupe slot while the ready client is stale', () => { + mount(); + advanceSettleWindow(); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + + // The account switch bumps the generation while the old client is still + // ready. `captureScreen` would silently drop the event, so the hook must + // neither capture nor mark HOME as captured for generation 2. + mocks.state.generation = 2; + advance(SCREEN_TRACKING_GENERATION_POLL_MS); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + + // The consent gate discards the stale client and re-inits it under + // generation 2. The first valid new-generation capture must still occur. + flipPostHogReady(false); + flipPostHogReady(true); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(2); + expect(mocks.captureScreen).toHaveBeenLastCalledWith(HOME); + }); + + it('never captures the redirect-only (app) production representation', () => { + mocks.state.segments = ['(app)']; + mount(); + advanceSettleWindow(); + + expect(mocks.captureScreen).not.toHaveBeenCalled(); + }); + + it('captures real (app) leaves that are not the redirect target', () => { + mocks.state.segments = ['(app)', 'onboarding']; + mount(); + advanceSettleWindow(); + + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith('(app)/onboarding'); + }); + + it('never captures KiloClaw leaves but still publishes them as settled', () => { + mocks.state.segments = ['(app)', '(tabs)', '(1_kiloclaw)']; + mount(); + advanceSettleWindow(); + + expect(mocks.captureScreen).not.toHaveBeenCalled(); + expect(getSettledLeafRoute()).toBe('(app)/(tabs)/(1_kiloclaw)'); + }); + + it('never captures while the consent bootstrap is unsettled', () => { + const renderer = mount(false); + advanceSettleWindow(); + expect(mocks.captureScreen).not.toHaveBeenCalled(); + + rerender(renderer, true); + expect(mocks.captureScreen).toHaveBeenCalledTimes(1); + expect(mocks.captureScreen).toHaveBeenCalledWith(HOME); + }); + + it('does not capture empty segments or publish an empty leaf', () => { + mocks.state.segments = []; + mount(); + advanceSettleWindow(); + + expect(mocks.captureScreen).not.toHaveBeenCalled(); + expect(getSettledLeafRoute()).toBeNull(); + }); + + it('logs each capture in dev builds', () => { + vi.stubGlobal('__DEV__', true); + const logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined); + + mount(); + advanceSettleWindow(); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('[screen-tracking]', HOME); + }); + + it('does not log when capture is skipped', () => { + vi.stubGlobal('__DEV__', true); + const logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined); + mocks.state.segments = ['(app)', '(tabs)', '(1_kiloclaw)']; + + mount(); + advanceSettleWindow(); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('publishes each settled leaf to the route-lifecycle signal', () => { + const seen: (string | null)[] = []; + const unsubscribe = subscribeSettledLeafRoute(() => { + seen.push(getSettledLeafRoute()); + }); + + const renderer = mount(); + advanceSettleWindow(); + expect(seen).toEqual([HOME]); + + mocks.state.segments = ['(app)', '(tabs)', '(3_profile)']; + rerender(renderer); + advanceSettleWindow(); + expect(seen).toEqual([HOME, PROFILE]); + + unsubscribe(); + }); +}); + +describe('route-lifecycle signal', () => { + beforeEach(() => { + resetSettledLeafRouteForTests(); + }); + + it('exposes a read-only get/subscribe signal and dedupes identical publishes', () => { + expect(getSettledLeafRoute()).toBeNull(); + + const seen: (string | null)[] = []; + const unsubscribe = subscribeSettledLeafRoute(() => { + seen.push(getSettledLeafRoute()); + }); + + publishSettledLeafRoute(HOME); + publishSettledLeafRoute(HOME); + publishSettledLeafRoute(PROFILE); + + expect(seen).toEqual([HOME, PROFILE]); + + unsubscribe(); + publishSettledLeafRoute('(app)/force-update'); + expect(getSettledLeafRoute()).toBe('(app)/force-update'); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-screen-tracking.ts b/apps/mobile/src/lib/hooks/use-screen-tracking.ts index 9983006e2f..295d922e3e 100644 --- a/apps/mobile/src/lib/hooks/use-screen-tracking.ts +++ b/apps/mobile/src/lib/hooks/use-screen-tracking.ts @@ -1,20 +1,165 @@ -import { useSegments } from 'expo-router'; -import { useEffect } from 'react'; +import { useNavigationContainerRef, useSegments } from 'expo-router'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import { captureScreen } from '@/lib/analytics/posthog'; +import { captureScreen, isPostHogReady, subscribeToPostHogReady } from '@/lib/analytics/posthog'; +import { + decideScreenTracking, + SCREEN_TRACKING_SETTLE_DEBOUNCE_MS, + type ScreenTrackingCapture, +} from '@/lib/hooks/screen-tracking-decision'; +import { publishSettledLeafRoute } from '@/lib/route-lifecycle'; +import { allowsOptional, currentGeneration } from '@/lib/telemetry/controller'; + +// How often the hook polls the telemetry generation counter. The controller +// exposes no subscription and account switches are rare; a short poll keeps the +// new account's first settled leaf captured without coupling to PostHog +// readiness or a manual rerender. +export const SCREEN_TRACKING_GENERATION_POLL_MS = 500; /** - * Captures a PostHog $screen event on every route change. Route segments keep - * their bracket placeholders (e.g. `chat/[sandbox-id]`), so no IDs or other - * dynamic values ever leave the device. + * Captures a PostHog `$screen` event for the settled visible leaf route. + * + * A route only counts as settled when the navigation state is not stale and + * the segments stayed unchanged for `SCREEN_TRACKING_SETTLE_DEBOUNCE_MS`. + * The settled marker is invalidated on every segment change, so even a + * previously settled leaf that is revisited within the window gets a fresh + * quiet period before it can capture or publish again. Hidden redirects flip + * segments immediately, so transient routes never survive the window. + * `analyticsReady` comes from the PostHog readiness subscription, so a late + * client init still captures the first settled leaf. The capture is accepted + * only when the ready PostHog client belongs to the current account + * generation — a stale client silently drops events, so it never consumes the + * new generation's dedupe slot. `bootstrapSettled` is the layout's + * consent-settled boolean. + * + * The settled leaf is also published to the shared route-lifecycle signal, + * independent of analytics eligibility: that signal is a visibility contract + * for other consumers, not a capture gate. + * + * Screen names keep their bracket placeholders (e.g. `chat/[sandbox-id]`), so + * no IDs or other dynamic values ever leave the device. In dev builds each + * capture is logged as `[screen-tracking] ` so bot E2E can assert the + * captured leaves (PostHog is disabled in dev builds). */ -export function useScreenTracking(): void { +export function useScreenTracking(bootstrapSettled: boolean): void { const segments = useSegments(); - const screenName = segments.join('/'); + const analyticsReady = useSyncExternalStore(subscribeToPostHogReady, isPostHogReady); + const lastCapturedRef = useRef(null); + + // Start a fresh settle window on every segment change and invalidate the + // previous window's settled marker immediately. Without the invalidation a + // route that settled earlier and is revisited within the window would + // inherit the old marker and count as settled before its own quiet period + // elapses: every segment change gets a full settle window. + const segmentsKey = segments.join('/'); + const [settledSegmentsKey, setSettledSegmentsKey] = useState(null); + useEffect(() => { + setSettledSegmentsKey(null); + const timer = setTimeout(() => { + setSettledSegmentsKey(segmentsKey); + }, SCREEN_TRACKING_SETTLE_DEBOUNCE_MS); + return () => { + clearTimeout(timer); + }; + }, [segmentsKey]); + + // Navigation readiness is a subscription, not a one-shot read: + // `useRootNavigationState` returns a static snapshot, so a stale-to-false + // transition would never re-evaluate without a manual rerender. Subscribe to + // the navigation container's `state` events and read the root state on each + // one. The cast widens the runtime state shape; oxlint's + // no-unnecessary-condition resolves the optional chain. + const navigationRef = useNavigationContainerRef(); + const [navState, setNavState] = useState<{ stale?: boolean } | undefined>( + () => navigationRef.current?.getRootState() as { stale?: boolean } | undefined + ); + useEffect(() => { + const update = () => { + setNavState(navigationRef.current?.getRootState() as { stale?: boolean } | undefined); + }; + update(); + return navigationRef.addListener('state', update); + }, [navigationRef]); + + const settled = settledSegmentsKey === segmentsKey && navState?.stale === false; + + // Publish the settled leaf to the shared route-lifecycle signal. This is a + // visibility signal, so it fires for every settled leaf regardless of + // analytics readiness or consent. + useEffect(() => { + if (!settled) { + return; + } + const leaf = segments.join('/'); + if (leaf === '') { + return; + } + publishSettledLeafRoute(leaf); + }, [settled, segments]); + + // Re-evaluate on account generation changes. The telemetry controller + // exposes no subscription, so poll its generation counter while mounted. + const [generationTick, setGenerationTick] = useState(0); + useEffect(() => { + let lastGeneration = currentGeneration(); + const timer = setInterval(() => { + const nextGeneration = currentGeneration(); + if (nextGeneration !== lastGeneration) { + lastGeneration = nextGeneration; + setGenerationTick(tick => tick + 1); + } + }, SCREEN_TRACKING_GENERATION_POLL_MS); + return () => { + clearInterval(timer); + }; + }, []); + // The generation of the PostHog client that is currently ready. The client + // generation is not exported by the analytics module, so it is observed at + // the moment readiness becomes true: `initPostHog` records the generation + // immediately before notifying readiness. `captureScreen` silently drops + // events when the client's generation does not match the current account + // generation, so a capture is only accepted (and only marks the dedupe key) + // when the ready client belongs to the current generation. + const [postHogClientGeneration, setPostHogClientGeneration] = useState(() => + isPostHogReady() ? currentGeneration() : null + ); useEffect(() => { - if (screenName) { - captureScreen(screenName); + if (isPostHogReady()) { + setPostHogClientGeneration(currentGeneration()); + } + }, [analyticsReady]); + + // Decide and capture. Re-evaluates when analytics becomes ready or its + // client generation is observed, the account generation changes, or a + // segment change re-settles, so the first settled leaf is captured rather + // than dropped. + useEffect(() => { + const generation = currentGeneration(); + const decision = decideScreenTracking({ + segments, + settled, + analyticsReady, + bootstrapSettled, + accountGeneration: generation, + captureAccepted: analyticsReady && allowsOptional() && postHogClientGeneration === generation, + lastCaptured: lastCapturedRef.current, + }); + if (!decision.capture) { + return; + } + lastCapturedRef.current = { generation, screenName: decision.screenName }; + captureScreen(decision.screenName); + if (__DEV__) { + // eslint-disable-next-line no-console -- dev-only E2E assertion hook for screen tracking + console.log('[screen-tracking]', decision.screenName); } - }, [screenName]); + }, [ + segments, + settled, + analyticsReady, + bootstrapSettled, + generationTick, + postHogClientGeneration, + ]); } diff --git a/apps/mobile/src/lib/route-lifecycle.ts b/apps/mobile/src/lib/route-lifecycle.ts new file mode 100644 index 0000000000..d36b1c9627 --- /dev/null +++ b/apps/mobile/src/lib/route-lifecycle.ts @@ -0,0 +1,44 @@ +/** + * Read-only route lifecycle signal: the current settled visible leaf route. + * + * `use-screen-tracking` publishes the leaf once navigation is not stale and + * the segments stayed stable for the settle window. Consumers (W3-C's + * accessibility/focus work and analytics) read the same value through + * `getSettledLeafRoute()` / `subscribeSettledLeafRoute()`. + * + * This module has no focus or accessibility behavior of its own — it only + * exposes the shared signal. + */ + +let settledLeafRoute: string | null = null; + +const listeners = new Set<() => void>(); + +/** The current settled leaf route, or null before the first route settles. */ +export function getSettledLeafRoute(): string | null { + return settledLeafRoute; +} + +/** Subscribe to changes of the settled leaf route. Returns an unsubscribe. */ +export function subscribeSettledLeafRoute(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Publish a settled leaf. No-op when the value is unchanged. */ +export function publishSettledLeafRoute(leaf: string): void { + if (leaf === settledLeafRoute) { + return; + } + settledLeafRoute = leaf; + for (const listener of listeners) { + listener(); + } +} + +/** Reset the signal. For tests only. */ +export function resetSettledLeafRouteForTests(): void { + settledLeafRoute = null; +} From 6695dbcc7d3d0498ba6a05614413e33faccd2657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 03:55:37 +0200 Subject: [PATCH 05/56] feat(cloud-agent): make session creation retry safe --- .../cloud-agent-next/cloud-agent-client.ts | 13 + .../src/routers/cloud-agent-next-schemas.ts | 13 + .../src/cloud-agent-next-client.test.ts | 32 + .../src/cloud-agent-next-client.ts | 12 + .../router/handlers/session-prepare.test.ts | 337 +++++++ .../src/router/handlers/session-prepare.ts | 54 +- .../cloud-agent-next/src/router/schemas.ts | 13 + .../src/session/session-prepare.test.ts | 939 ++++++++++++++++++ .../src/session/session-registration.ts | 575 ++++++++++- .../src/session/session-requests.ts | 7 + 10 files changed, 1948 insertions(+), 47 deletions(-) create mode 100644 services/cloud-agent-next/src/router/handlers/session-prepare.test.ts create mode 100644 services/cloud-agent-next/src/session/session-prepare.test.ts diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 0b14fe34e6..055193aa67 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -144,6 +144,13 @@ export type PrepareSessionInput = { /** When true, route the session to a Docker-in-Docker sandbox that supports devcontainer runtimes */ devcontainer?: boolean; initialMessageId?: string | null; + /** + * Client-generated UUID, stable across retries of one user intent. The + * cloud-agent-next worker admits the create into its operation ledger + * only when this is present AND the effective `autoInitiate` is true; + * otherwise it is ignored (legacy behavior preserved). + */ + operationKey?: string; }; /** Output from prepareSession procedure */ @@ -151,6 +158,12 @@ export type PrepareSessionOutput = { /** The Kilo CLI session ID */ kiloSessionId: string; cloudAgentSessionId: string; + /** + * `true` when this response is a ledger replay of an already-settled + * create (same `operationKey`). The canonical session IDs are returned + * unchanged; the caller should not create a second session row. + */ + replayed?: boolean; }; /** Input for initiating from a prepared session */ diff --git a/apps/web/src/routers/cloud-agent-next-schemas.ts b/apps/web/src/routers/cloud-agent-next-schemas.ts index 0f9b98b007..24cd1415d4 100644 --- a/apps/web/src/routers/cloud-agent-next-schemas.ts +++ b/apps/web/src/routers/cloud-agent-next-schemas.ts @@ -377,6 +377,13 @@ export const basePrepareSessionNextSchema = z attachments: cloudAgentAttachmentsSchema.optional(), images: cloudAgentImagesSchema, devcontainer: z.boolean().optional(), + /** + * Client-generated UUID, stable across retries of one user intent. The + * cloud-agent-next worker admits the create into its operation ledger + * only when this is present AND the effective `autoInitiate` is true; + * otherwise it is ignored (legacy behavior preserved). + */ + operationKey: z.string().uuid().optional(), }) .refine( data => @@ -405,6 +412,12 @@ export const personalPrepareSessionNextSchema = basePrepareSessionNextSchema.ref export const basePrepareSessionNextOutputSchema = z.object({ kiloSessionId: z.string().startsWith('ses_').length(30), cloudAgentSessionId: z.string(), + /** + * `true` when this response is a ledger replay of an already-settled + * create (same `operationKey`). The canonical session IDs are returned + * unchanged; the caller should not create a second session row. + */ + replayed: z.boolean().optional(), }); // Schema for initiating from a prepared session diff --git a/packages/worker-utils/src/cloud-agent-next-client.test.ts b/packages/worker-utils/src/cloud-agent-next-client.test.ts index ea3d8b997b..6560b66c80 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.test.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.test.ts @@ -56,6 +56,38 @@ describe('CloudAgentNextFetchClient prepareSession', () => { expect.objectContaining({ body: JSON.stringify(input) }) ); }); + + it('forwards operationKey on the wire and surfaces replayed on the output', async () => { + const fetchMock = mockFetch(200, { + result: { + data: { + cloudAgentSessionId: 'agent_123', + kiloSessionId: 'ses_123', + replayed: true, + }, + }, + }); + vi.stubGlobal('fetch', fetchMock); + const client = createCloudAgentNextFetchClient(BASE_URL); + const input: CloudAgentPrepareSessionInput = { + prompt: 'test', + mode: 'code', + model: 'test-model', + operationKey: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + }; + + const output = await client.prepareSession({}, input); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/trpc/prepareSession`, + expect.objectContaining({ body: JSON.stringify(input) }) + ); + expect(output).toEqual({ + cloudAgentSessionId: 'agent_123', + kiloSessionId: 'ses_123', + replayed: true, + }); + }); }); describe('CloudAgentNextFetchClient billing error detection', () => { diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index df19e71311..623b3b3e21 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -67,11 +67,23 @@ export type CloudAgentPrepareSessionInput = { // Inline per-session agents. For council runs, one subagent per specialist, each pinned // to its own model/effort; cloud-agent-next materializes these into KILO_CONFIG agents. runtimeAgents?: RuntimeAgentInput[]; + /** + * Client-generated UUID, stable across retries of one user intent. When + * present AND the effective `autoInitiate` is true, cloud-agent-next admits + * the create into the operation ledger and dedupes same-key replays. + */ + operationKey?: string; }; export type CloudAgentPrepareSessionOutput = { cloudAgentSessionId: string; kiloSessionId: string; + /** + * `true` when this response is a ledger replay of an already-settled + * create (same `operationKey`). The canonical session IDs are returned + * unchanged; the caller should not create a second session row. + */ + replayed?: boolean; }; export type CloudAgentInitiateInput = { diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts new file mode 100644 index 0000000000..29ef752398 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts @@ -0,0 +1,337 @@ +/** + * Focused handler tests for the prepareSession operation-ledger admission + * gate (plan P1-A-08b): `operationKey` + `autoInitiate` propagation to + * `createSessionWithLedger`, the legacy fallback gates, and the `replayed` + * output flag. + * + * The ledger functions in `session-registration.js` are mocked so the gate + * logic in the handler is exercised deterministically; the full ladder is + * covered by `src/session/session-prepare.test.ts`. + */ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as CloudAgentProfile from '@kilocode/cloud-agent-profile'; + +import { t } from '../auth.js'; +import type { TRPCContext } from '../../types.js'; +import { createSessionPrepareHandlers } from './session-prepare.js'; + +const { + mergeProfileConfigurationMock, + assertKiloModelAvailableMock, + assertBitbucketRepositoryAccessMock, + assertOrganizationMembershipMock, + registerNewSessionMock, + startNewSessionMock, + createSessionWithLedgerMock, +} = vi.hoisted(() => ({ + mergeProfileConfigurationMock: vi.fn().mockResolvedValue({}), + assertKiloModelAvailableMock: vi.fn().mockResolvedValue(undefined), + assertBitbucketRepositoryAccessMock: vi.fn().mockResolvedValue(undefined), + assertOrganizationMembershipMock: vi.fn().mockResolvedValue(undefined), + registerNewSessionMock: vi.fn().mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + initialTurn: { + type: 'prompt', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + prompt: 'test', + }, + }), + startNewSessionMock: vi.fn().mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + admission: { + success: true, + outcome: 'queued', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + compatibilityDelivery: 'queued', + }, + }), + createSessionWithLedgerMock: vi.fn().mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + }), +})); + +vi.mock('@kilocode/cloud-agent-profile', async importActual => { + const actual = await importActual(); + return { + ...actual, + mergeProfileConfiguration: mergeProfileConfigurationMock, + }; +}); + +vi.mock('../../db/pg.js', () => ({ + getPgDb: vi.fn(() => ({ mockedDb: true })), +})); + +vi.mock('../../model-validation.js', () => ({ + assertKiloModelAvailable: assertKiloModelAvailableMock, +})); + +vi.mock('../../session/validate-repository-access.js', () => ({ + assertBitbucketRepositoryAccessBeforeSessionCreation: assertBitbucketRepositoryAccessMock, +})); + +vi.mock('./organization-membership.js', () => ({ + assertOrganizationMembership: assertOrganizationMembershipMock, +})); + +vi.mock('../../session/session-registration.js', () => ({ + registerNewSession: registerNewSessionMock, + startNewSession: startNewSessionMock, + createSessionWithLedger: createSessionWithLedgerMock, +})); + +const handlers = createSessionPrepareHandlers(); +const router = t.router({ + prepareSession: handlers.prepareSession, + updateSession: handlers.updateSession, +}); + +function createContext(overrides?: { + userId?: string; + organizationMembership?: boolean; +}): TRPCContext { + const headers = new Headers(); + headers.set('x-internal-api-key', 'test-internal-api-secret'); + if (overrides?.organizationMembership === false) { + assertOrganizationMembershipMock.mockRejectedValueOnce( + new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to this organization' }) + ); + } + return { + userId: overrides?.userId ?? 'test-user-123', + authToken: 'test-auth-token', + request: { headers } as Request, + env: { + INTERNAL_API_SECRET: 'test-internal-api-secret', + HYPERDRIVE: { + connectionString: 'postgres://handler-test', + }, + } as unknown as TRPCContext['env'], + } as TRPCContext; +} + +const OPERATION_KEY = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + +describe('prepareSession operation-ledger admission gate', () => { + beforeEach(() => { + vi.clearAllMocks(); + mergeProfileConfigurationMock.mockResolvedValue({}); + assertKiloModelAvailableMock.mockResolvedValue(undefined); + assertBitbucketRepositoryAccessMock.mockResolvedValue(undefined); + assertOrganizationMembershipMock.mockResolvedValue(undefined); + registerNewSessionMock.mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + initialTurn: { + type: 'prompt', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + prompt: 'test', + }, + }); + startNewSessionMock.mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + admission: { + success: true, + outcome: 'queued', + messageId: 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', + compatibilityDelivery: 'queued', + }, + }); + createSessionWithLedgerMock.mockResolvedValue({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + }); + }); + + it('propagates operationKey to the ledger create only when autoInitiate is also true', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + }); + + expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(1); + expect(createSessionWithLedgerMock).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + }), + }), + expect.objectContaining({ userId: 'test-user-123', authToken: 'test-auth-token' }), + expect.objectContaining({ + billingOrigin: 'cloud-agent-web', + operationKey: OPERATION_KEY, + startedAt: expect.any(Number), + }) + ); + expect(startNewSessionMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); + }); + + it('ignores operationKey when autoInitiate is false and retains legacy registration', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: false, + operationKey: OPERATION_KEY, + }); + + expect(registerNewSessionMock).toHaveBeenCalledTimes(1); + expect(registerNewSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ operationKey: OPERATION_KEY }), + }), + expect.objectContaining({ userId: 'test-user-123' }), + { billingOrigin: undefined } + ); + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).not.toHaveBeenCalled(); + }); + + it('ignores operationKey when autoInitiate is omitted', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + operationKey: OPERATION_KEY, + }); + + expect(registerNewSessionMock).toHaveBeenCalledTimes(1); + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).not.toHaveBeenCalled(); + }); + + it('uses grouped startNewSession when autoInitiate is true without an operationKey', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + }); + + expect(startNewSessionMock).toHaveBeenCalledTimes(1); + expect(startNewSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.not.objectContaining({ operationKey: expect.anything() }), + }), + expect.objectContaining({ userId: 'test-user-123' }), + { billingOrigin: undefined } + ); + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); + }); + + it('does not reach the ledger when organization membership is rejected', async () => { + const caller = router.createCaller(createContext({ organizationMembership: false })); + const organizationId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + + await expect( + caller.prepareSession({ + prompt: 'Attempt organization attribution', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + kilocodeOrganizationId: organizationId, + autoInitiate: true, + operationKey: OPERATION_KEY, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + expect(registerNewSessionMock).not.toHaveBeenCalled(); + expect(startNewSessionMock).not.toHaveBeenCalled(); + }); + + it('does not reach the ledger when the model preflight rejects', async () => { + const caller = router.createCaller(createContext()); + assertKiloModelAvailableMock.mockRejectedValue( + new TRPCError({ code: 'BAD_REQUEST', message: 'Selected model is not available' }) + ); + + await expect( + caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'missing/model', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(createSessionWithLedgerMock).not.toHaveBeenCalled(); + }); + + it('returns replayed true only when the ledger replays a settled create', async () => { + createSessionWithLedgerMock.mockResolvedValueOnce({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + replayed: true, + }); + const caller = router.createCaller(createContext()); + + const result = await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + }); + + expect(result).toEqual({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + replayed: true, + }); + }); + + it('omits the replayed key on a fresh ledger create', async () => { + const caller = router.createCaller(createContext()); + + const result = await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + }); + + expect(result).toEqual({ + cloudAgentSessionId: 'agent_12345678-1234-1234-1234-123456789abc', + kiloSessionId: 'cli-session-abc123', + }); + expect(result).not.toHaveProperty('replayed'); + }); +}); diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.ts index 4bfc46c15f..94d7f934ee 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.ts @@ -36,7 +36,11 @@ import { UpdateSessionOutput, isBuiltinMode, } from '../schemas.js'; -import { registerNewSession, startNewSession } from '../../session/session-registration.js'; +import { + registerNewSession, + startNewSession, + createSessionWithLedger, +} from '../../session/session-registration.js'; import { getPgDb } from '../../db/pg.js'; import type { Env } from '../../types.js'; import type { SessionProfileBundle } from '../../session-profile.js'; @@ -289,6 +293,7 @@ export function prepareInputToSessionCreateRequest(input: PrepareInput): Session kilocodeOrganizationId: input.kilocodeOrganizationId, createdOnPlatform: input.createdOnPlatform, shallow: input.shallow, + operationKey: input.operationKey, }, }; } @@ -362,33 +367,36 @@ const prepareSessionHandler = internalApiProtectedProcedure }); } + const operationKey = requestWithProfile.options?.operationKey; + const registrationContext = { + env: ctx.env, + userId: ctx.userId, + authToken: ctx.authToken, + botId: ctx.botId, + }; + const billingOrigin = { billingOrigin: input.createdOnPlatform }; + // Admit into the operation ledger only when the client supplied an + // `operationKey` AND the effective `autoInitiate` is true. Otherwise the + // key is ignored and the legacy split-flow behavior is preserved. const result = - input.autoInitiate === true - ? await startNewSession( - requestWithProfile, - { - env: ctx.env, - userId: ctx.userId, - authToken: ctx.authToken, - botId: ctx.botId, - }, - { billingOrigin: input.createdOnPlatform } - ) - : await registerNewSession( - requestWithProfile, - { - env: ctx.env, - userId: ctx.userId, - authToken: ctx.authToken, - botId: ctx.botId, - }, - { billingOrigin: input.createdOnPlatform } - ); + input.autoInitiate === true && operationKey + ? await createSessionWithLedger(requestWithProfile, registrationContext, { + ...billingOrigin, + operationKey, + startedAt: Date.now(), + }) + : input.autoInitiate === true + ? await startNewSession(requestWithProfile, registrationContext, billingOrigin) + : await registerNewSession(requestWithProfile, registrationContext, billingOrigin); - return { + const response = { cloudAgentSessionId: result.cloudAgentSessionId, kiloSessionId: result.kiloSessionId, }; + if ('replayed' in result && result.replayed === true) { + return { ...response, replayed: true }; + } + return response; }); }); diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts index c5829b5ada..1ffd1ff5c9 100644 --- a/services/cloud-agent-next/src/router/schemas.ts +++ b/services/cloud-agent-next/src/router/schemas.ts @@ -544,6 +544,13 @@ export const PrepareSessionInput = z initialPayload: SendMessageV2Payload.optional().describe( 'Discriminated initial execution payload - command variant allows starting a session with a slash command instead of a free-text prompt' ), + operationKey: z + .string() + .uuid() + .optional() + .describe( + 'Client-generated UUID, stable across retries of one user intent. Admitted into the operation ledger only when autoInitiate is also true; otherwise ignored.' + ), }) .refine(validateGitSource, { message: 'Must provide either githubRepo or gitUrl, but not both', @@ -642,6 +649,12 @@ export const PrepareSessionInput = z export const PrepareSessionOutput = z.object({ cloudAgentSessionId: z.string().describe('The generated cloud-agent session ID'), kiloSessionId: z.string().describe('The Kilo CLI session ID'), + replayed: z + .boolean() + .optional() + .describe( + 'True when this response is a ledger replay of an already-settled create with the same operationKey' + ), }); /** diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts new file mode 100644 index 0000000000..3fb79fd481 --- /dev/null +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -0,0 +1,939 @@ +/** + * Ledger-guarded session creation ladder (plan P1-A-08b step 3): + * `createSessionWithLedger` admission outcomes and the takeover / + * reconcile-pending reconciliation ladder in `session-registration.ts`. + * + * The operation-ledger helpers, session-ingest path, sandbox routing, and the + * Durable Object RPC transport are mocked so each ladder branch is exercised + * deterministically; `startNewSession` and the reconcile ladder run real code. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkerDb } from '@kilocode/db/client'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; + +import type { Env } from '../types.js'; +import type { SessionMetadata } from '../persistence/session-metadata.js'; +import type { MessageResultRPCResponse } from './message-result.js'; +import type { SessionMessageAdmissionResult } from '../execution/types.js'; +import type { SessionCreateRequest } from './session-requests.js'; +import type * as SandboxIdModule from '../sandbox-id.js'; +import type * as SharedSandboxRouteModule from '../shared-sandbox-route.js'; +import { + createSessionWithLedger, + type SessionRegistrationContext, +} from './session-registration.js'; + +const { + admitOperationMock, + settleOperationMock, + markReconcilePendingMock, + recordOperationProgressMock, + getPgDbMock, + createCliSessionMock, + deleteCliSessionMock, + generateSessionIdMock, + generateKiloSessionIdMock, + createSessionReportMock, + recordSandboxIdentityMock, + recordSessionFailureMock, + generateSandboxRoutingTargetMock, +} = vi.hoisted(() => ({ + admitOperationMock: vi.fn(), + settleOperationMock: vi.fn().mockResolvedValue({ settled: true }), + markReconcilePendingMock: vi.fn().mockResolvedValue({}), + recordOperationProgressMock: vi.fn().mockResolvedValue(undefined), + getPgDbMock: vi.fn(), + createCliSessionMock: vi.fn().mockResolvedValue(undefined), + deleteCliSessionMock: vi.fn().mockResolvedValue(undefined), + generateSessionIdMock: vi.fn(), + generateKiloSessionIdMock: vi.fn(), + createSessionReportMock: vi.fn().mockResolvedValue(undefined), + recordSandboxIdentityMock: vi.fn().mockResolvedValue(undefined), + recordSessionFailureMock: vi.fn().mockResolvedValue(undefined), + generateSandboxRoutingTargetMock: vi.fn(), +})); + +vi.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: admitOperationMock, + settleOperation: settleOperationMock, + markReconcilePending: markReconcilePendingMock, + recordOperationProgress: recordOperationProgressMock, +})); + +vi.mock('../db/pg.js', () => ({ + getPgDb: getPgDbMock, +})); + +vi.mock('../utils/do-retry.js', () => ({ + withDORetry: (getStub: () => unknown, operation: (stub: unknown) => unknown): unknown => + operation(getStub()), +})); + +vi.mock('../session-service.js', () => ({ + generateSessionId: () => generateSessionIdMock(), + SessionService: class SessionService { + createCliSessionViaSessionIngest = createCliSessionMock; + deleteCliSessionViaSessionIngest = deleteCliSessionMock; + }, +})); + +vi.mock('../telemetry/session-reports.js', () => ({ + createCloudAgentSessionReport: createSessionReportMock, + recordCloudAgentSandboxIdentity: recordSandboxIdentityMock, + recordCloudAgentSessionFailure: recordSessionFailureMock, +})); + +vi.mock('../utils/kilo-session-id.js', () => ({ + generateKiloSessionId: () => generateKiloSessionIdMock(), +})); + +vi.mock('./message-id.js', () => ({ + createMessageId: () => 'msg_018f1e2d3c4bAbCdEfGhIjKlMn', +})); + +vi.mock('../sandbox-id.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + generateSandboxRoutingTarget: generateSandboxRoutingTargetMock, + }; +}); + +vi.mock('../shared-sandbox-route.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + resolveSharedSandboxAssignment: vi.fn(), + }; +}); + +const OPERATION_KEY = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; +const USER_ID = 'test-user-123'; +const AUTH_TOKEN = 'test-auth-token'; +const CLOUD_AGENT_SESSION_ID = 'agent_12345678-1234-1234-1234-123456789abc'; +const KILO_SESSION_ID = 'ses_12345678901234567890123456'; +const INITIAL_MESSAGE_ID = 'msg_018f1e2d3c4bAbCdEfGhIjKlMn'; +const ROW_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + +function makeLedgerRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: ROW_ID, + operation_key: OPERATION_KEY, + domain: 'session', + intent: 'create_cloud', + kilo_user_id: USER_ID, + organization_id: null, + resource_key: null, + provider_ref: null, + taxonomy: 'safe-retry', + status: 'admitted', + outcome_code: null, + canonical_result: null, + admitted_at: '2026-08-06T00:00:00.000Z', + settled_at: null, + lease_expires_at: '2026-08-06T02:00:00.000Z', + expires_at: '2026-09-05T00:00:00.000Z', + ...overrides, + }; +} + +/** Fake Drizzle db: `.limit(1)` returns the next queued result per query. */ +function makeDb(limitResults: unknown[][]): WorkerDb { + const limit = vi.fn(async () => limitResults.shift() ?? []); + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit })), + })), + })); + return { select } as unknown as WorkerDb; +} + +type DoStubOverrides = { + createSessionWithInitialAdmission?: ReturnType; + getMetadata?: ReturnType; + getMessageResult?: ReturnType; +}; + +function makeDoStub(overrides: DoStubOverrides = {}) { + return { + createSessionWithInitialAdmission: + overrides.createSessionWithInitialAdmission ?? + vi.fn().mockResolvedValue({ + success: true, + outcome: 'queued', + messageId: INITIAL_MESSAGE_ID, + compatibilityDelivery: 'queued', + } satisfies SessionMessageAdmissionResult), + getMetadata: + overrides.getMetadata ?? + vi.fn().mockResolvedValue({ + identity: { sessionId: CLOUD_AGENT_SESSION_ID }, + } as SessionMetadata), + getMessageResult: + overrides.getMessageResult ?? + vi.fn().mockResolvedValue({ + type: 'found', + result: { + messageId: INITIAL_MESSAGE_ID, + status: 'queued', + createdAt: 1, + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + }, + } satisfies MessageResultRPCResponse), + }; +} + +function makeEnv(doStub: ReturnType): Env { + return { + CLOUD_AGENT_SESSION: { + idFromName: vi.fn((name: string) => ({ toString: () => name })), + get: vi.fn(() => doStub), + } as unknown as Env['CLOUD_AGENT_SESSION'], + HYPERDRIVE: { + connectionString: 'postgres://session-create-test', + } as Env['HYPERDRIVE'], + } as unknown as Env; +} + +function makeContext(doStub: ReturnType): SessionRegistrationContext { + return { + env: makeEnv(doStub), + userId: USER_ID, + authToken: AUTH_TOKEN, + }; +} + +function makeRequest(overrides: Partial = {}): SessionCreateRequest { + return { + initialTurn: { type: 'prompt', prompt: 'Build the feature' }, + agent: { mode: 'code', model: 'claude-3' }, + repository: { type: 'github', repo: 'acme/repo' }, + ...overrides, + }; +} + +type SettleOptions = { outboxEvent?: { properties?: unknown } }; + +/** Returns the options argument of the n-th `settleOperation` call, if any. */ +function settleOptions(index: number): SettleOptions | undefined { + const call = settleOperationMock.mock.calls[index]; + if (!call) return undefined; + return call[1] as SettleOptions | undefined; +} + +describe('createSessionWithLedger admission ladder', () => { + beforeEach(() => { + vi.clearAllMocks(); + getPgDbMock.mockReturnValue(makeDb([[{ email: 'test@example.com' }]])); + generateSessionIdMock.mockReturnValue(CLOUD_AGENT_SESSION_ID); + generateKiloSessionIdMock.mockReturnValue(KILO_SESSION_ID); + generateSandboxRoutingTargetMock.mockResolvedValue({ + kind: 'isolated', + sandboxId: 'sb-test-123', + }); + admitOperationMock.mockResolvedValue({ + admission: 'admitted', + row: makeLedgerRow({}), + }); + settleOperationMock.mockResolvedValue({ settled: true }); + markReconcilePendingMock.mockResolvedValue({}); + recordOperationProgressMock.mockResolvedValue(undefined); + }); + + it('admits with the operation identity and settles completed with canonical IDs', async () => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + { operationKey: OPERATION_KEY, startedAt: 1_700_000_000_000 } + ); + + expect(admitOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: USER_ID, + domain: 'session', + intent: 'create_cloud', + operationKey: OPERATION_KEY, + taxonomy: 'safe-retry', + leaseSeconds: 120, + }) + ); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ + sessionId: CLOUD_AGENT_SESSION_ID, + userId: USER_ID, + }), + workspace: expect.objectContaining({ sandboxId: 'sb-test-123' }), + message: expect.objectContaining({ + initialTurn: expect.objectContaining({ + messageId: INITIAL_MESSAGE_ID, + prompt: 'Build the feature', + }), + }), + }) + ); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + eventName: 'session_create_settled', + distinctId: 'test@example.com', + properties: { + outcome: 'completed', + admission: 'new', + in_organization: false, + }, + }); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }); + }); + + it('settles failed with the allocation stage when a pre-DO step fails', async () => { + createSessionReportMock.mockRejectedValueOnce(new Error('report store unavailable')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('report store unavailable'); + + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'report', + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'failed', admission: 'new', failure_stage: 'report' }, + }); + }); + + it('settles failed with the report stage when the progress write fails', async () => { + recordOperationProgressMock.mockRejectedValueOnce(new Error('progress write failed')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('progress write failed'); + + expect(createSessionReportMock).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'report', + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'failed', admission: 'new', failure_stage: 'report' }, + }); + }); + + it('settles failed with the sandbox stage when sandbox routing fails', async () => { + generateSandboxRoutingTargetMock.mockRejectedValueOnce(new Error('routing failed')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('routing failed'); + + expect(recordSessionFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + failure: { stage: 'sandbox_identity', code: 'sandbox_id_derivation_failed' }, + }), + expect.any(Object) + ); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'sandbox', + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'failed', admission: 'new', failure_stage: 'sandbox' }, + }); + }); + + it('rethrows the primary sandbox error when the telemetry failure record fails', async () => { + generateSandboxRoutingTargetMock.mockRejectedValueOnce(new Error('routing failed')); + recordSessionFailureMock.mockRejectedValueOnce(new Error('telemetry down')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('routing failed'); + + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'sandbox', + }) + ); + }); + + it('settles failed with the sandbox stage when the sandbox identity write fails', async () => { + recordSandboxIdentityMock.mockRejectedValueOnce(new Error('identity write failed')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('identity write failed'); + + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'sandbox', + }) + ); + }); + + it('settles failed with the ownership_row stage when the ownership write fails', async () => { + createCliSessionMock.mockRejectedValueOnce(new Error('session ingest unavailable')); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('session ingest unavailable'); + + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'ownership_row', + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'failed', admission: 'new', failure_stage: 'ownership_row' }, + }); + }); + + it('settles failed with the registration stage when the DO explicitly rejects', async () => { + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn().mockResolvedValue({ + success: false, + code: 'BAD_REQUEST', + error: 'registration rejected', + failureBoundary: 'registration', + } satisfies SessionMessageAdmissionResult), + }); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'registration rejected' }); + + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'do_registration_rejected', + }) + ); + expect(deleteCliSessionMock).toHaveBeenCalledWith( + KILO_SESSION_ID, + USER_ID, + expect.any(Object), + { onlyIfEmpty: true } + ); + expect(recordSessionFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + failure: { stage: 'registration', code: 'do_registration_rejected' }, + }), + expect.any(Object) + ); + }); + + it('settles failed with the initial_admission stage when admission is rejected', async () => { + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn().mockResolvedValue({ + success: false, + code: 'BAD_REQUEST', + error: 'intent rejected', + failureBoundary: 'admission', + } satisfies SessionMessageAdmissionResult), + }); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'failed', + outcomeCode: 'invalid_initial_intent', + }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'failed', admission: 'new', failure_stage: 'initial_admission' }, + }); + }); + + it('marks the row reconcile-pending when the DO transport outcome is unknown', async () => { + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn().mockRejectedValue(new Error('rpc timed out')), + }); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('rpc timed out'); + + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { + rowId: ROW_ID, + }); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(recordSessionFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + failure: { stage: 'transport', code: 'do_rpc_outcome_unknown' }, + }), + expect.any(Object) + ); + }); + + it('replays the settled create for a duplicate_settled admission', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: makeLedgerRow({ + status: 'completed', + canonical_result: { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }), + }); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + { operationKey: OPERATION_KEY, startedAt: 1_700_000_000_000 } + ); + + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects a settled row without canonical IDs as a non-retryable failure', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: makeLedgerRow({ + status: 'completed', + }), + }); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('never replays a successful result from a failed terminal row with progress IDs', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: makeLedgerRow({ + status: 'failed', + outcome_code: 'report', + canonical_result: { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + initialMessageId: INITIAL_MESSAGE_ID, + }, + }), + }); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('returns CONFLICT creation_in_progress for a duplicate_in_flight admission', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_in_flight', + row: makeLedgerRow({}), + }); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + + expect(settleOperationMock).not.toHaveBeenCalled(); + }); +}); + +describe('createSessionWithLedger takeover reconciliation ladder', () => { + beforeEach(() => { + vi.clearAllMocks(); + generateSessionIdMock.mockReturnValue(CLOUD_AGENT_SESSION_ID); + generateKiloSessionIdMock.mockReturnValue(KILO_SESSION_ID); + generateSandboxRoutingTargetMock.mockResolvedValue({ + kind: 'isolated', + sandboxId: 'sb-test-123', + }); + settleOperationMock.mockResolvedValue({ settled: true }); + markReconcilePendingMock.mockResolvedValue({}); + recordOperationProgressMock.mockResolvedValue(undefined); + }); + + const takeoverOptions = { operationKey: OPERATION_KEY, startedAt: 1_700_000_000_000 }; + const canonicalIds = { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + initialMessageId: INITIAL_MESSAGE_ID, + }; + + it('(a) runs a fresh create under the row when no progress IDs were recorded', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: null }), + }); + getPgDbMock.mockReturnValue(makeDb([[{ email: 'test@example.com' }]])); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'completed', admission: 'takeover' }, + }); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }); + }); + + it('(b) runs a fresh create when the ownership row is absent despite recorded IDs', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + // First query: ownership lookup returns no row. Second: user email. + getPgDbMock.mockReturnValue(makeDb([[], [{ email: 'test@example.com' }]])); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ properties: { admission: 'takeover' } }); + expect(result.cloudAgentSessionId).toBe(CLOUD_AGENT_SESSION_ID); + }); + + it('(c) removes a stale ownership row without DO metadata, then creates fresh', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + // First: ownership present. Second (after delete): ownership gone. Third: user email. + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [], [{ email: 'test@example.com' }]]) + ); + const doStub = makeDoStub({ + getMetadata: vi.fn().mockResolvedValue(null), + }); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(deleteCliSessionMock).toHaveBeenCalledWith( + KILO_SESSION_ID, + USER_ID, + expect.any(Object), + { onlyIfEmpty: true } + ); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ properties: { admission: 'takeover' } }); + expect(result.cloudAgentSessionId).toBe(CLOUD_AGENT_SESSION_ID); + }); + + it('(c-keep) confirms the live session only after authoritative metadata and admission', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + // First: ownership present. Second (after delete): still present. Third: user email. + getPgDbMock.mockReturnValue( + makeDb([ + [{ sessionId: KILO_SESSION_ID }], + [{ sessionId: KILO_SESSION_ID }], + [{ email: 'test@example.com' }], + ]) + ); + const doStub = makeDoStub({ + // First read: the DO has not registered yet. After the empty-only delete + // refuses, the authoritative re-read proves registration. + getMetadata: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + identity: { sessionId: CLOUD_AGENT_SESSION_ID }, + } as SessionMetadata), + getMessageResult: vi.fn().mockResolvedValue({ + type: 'found', + result: { + messageId: INITIAL_MESSAGE_ID, + status: 'queued', + createdAt: 1, + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + }, + } satisfies MessageResultRPCResponse), + }); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(deleteCliSessionMock).toHaveBeenCalledTimes(1); + expect(doStub.getMetadata).toHaveBeenCalledTimes(2); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'completed', + outcomeCode: 'ok', + }) + ); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + }); + + it('(c-keep) stays reconcile-pending when the DO never registers metadata', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + // First: ownership present. Second (after delete): still present. + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [{ sessionId: KILO_SESSION_ID }]]) + ); + const doStub = makeDoStub({ + getMetadata: vi.fn().mockResolvedValue(null), + // Contract check: without metadata the DO cannot report admission. + getMessageResult: vi.fn().mockResolvedValue({ + type: 'session-not-found', + } satisfies MessageResultRPCResponse), + }); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + + expect(deleteCliSessionMock).toHaveBeenCalledTimes(1); + expect(doStub.getMessageResult).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('(d) settles and replays when metadata exists and the initial message is admitted', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [{ email: 'test@example.com' }]]) + ); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(doStub.getMetadata).toHaveBeenCalledTimes(1); + expect(doStub.getMessageResult).toHaveBeenCalledWith(INITIAL_MESSAGE_ID); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ rowId: ROW_ID, status: 'completed', outcomeCode: 'ok' }) + ); + expect(settleOptions(0)?.outboxEvent).toMatchObject({ + properties: { outcome: 'completed', admission: 'takeover', in_organization: false }, + }); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + }); + + it('returns CONFLICT when the recorded initial message is not admitted', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + getPgDbMock.mockReturnValue(makeDb([[{ sessionId: KILO_SESSION_ID }]])); + const doStub = makeDoStub({ + getMessageResult: vi.fn().mockResolvedValue({ + type: 'message-not-found', + } satisfies MessageResultRPCResponse), + }); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + }); + + it('returns CONFLICT when a recorded initialMessageId is missing from the canonical result', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: makeLedgerRow({ + canonical_result: { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }), + }); + getPgDbMock.mockReturnValue(makeDb([[{ sessionId: KILO_SESSION_ID }]])); + const ctx = makeContext(makeDoStub()); + + await expect( + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('reconciles a duplicate_reconcile_pending row through the same ladder', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [{ email: 'test@example.com' }]]) + ); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ); + + expect(doStub.getMessageResult).toHaveBeenCalledWith(INITIAL_MESSAGE_ID); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + }); +}); diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 9f6ac210bb..5911301dd5 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -15,12 +15,24 @@ * metadata; generic git repositories may still carry an explicit token. */ import { TRPCError } from '@trpc/server'; +import { and, eq } from 'drizzle-orm'; +import type { WorkerDb } from '@kilocode/db/client'; +import { cli_sessions_v2, kilocode_users } from '@kilocode/db/schema'; +import { + admitOperation, + markReconcilePending, + recordOperationProgress, + settleOperation, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; import type { Env, SandboxId } from '../types.js'; import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; import type { CredentialContainment, SessionMetadata } from '../persistence/session-metadata.js'; import { logger } from '../logger.js'; import { withDORetry } from '../utils/do-retry.js'; +import { getPgDb } from '../db/pg.js'; import { generateSessionId, SessionService } from '../session-service.js'; import { createCloudAgentSessionReport, @@ -31,6 +43,7 @@ import { generateSandboxRoutingTarget, isOrgInList, type SandboxSelection } from import { resolveSharedSandboxAssignment } from '../shared-sandbox-route.js'; import { generateKiloSessionId } from '../utils/kilo-session-id.js'; import { createMessageId } from './message-id.js'; +import type { MessageResultRPCResponse } from './message-result.js'; import type { AcceptedExecutionTurn, ExecutionTurnSubmission, @@ -103,7 +116,7 @@ export function executionTurnSubmissionFromAcceptedTurn( }; } -type SessionEstablishmentFailure = +export type SessionEstablishmentFailure = | { stage: 'sandbox_identity'; code: 'sandbox_id_derivation_failed' } | { stage: 'registration'; code: 'do_registration_rejected' } | { @@ -118,6 +131,147 @@ type NewSessionAllocation = SessionRegistrationResult & { rollbackCliSession: () => Promise; }; +// ----- operation-ledger boundary (P1-A-08b) ----------------------------------- + +/** + * Allocation failure stages reported to the operation ledger, matching the + * `session_create_settled` `failure_stage` enum for pre-DO work. + */ +type SessionLedgerAllocationFailureStage = 'report' | 'sandbox' | 'ownership_row'; + +/** Session ledger `failure_stage` values (allocation + DO rejection). */ +export type SessionLedgerFailureStage = + | SessionLedgerAllocationFailureStage + | 'registration' + | 'initial_admission'; + +/** + * Optional ledger hooks threaded through creation so the create effect records + * progress and settles the operation exactly once. All hooks are best-effort: + * a ledger write failure must never mask the primary creation outcome. + */ +export type SessionCreationLedgerHooks = { + db: WorkerDb; + rowId: string; + /** Allocation failed after ID generation (report write, sandbox, ownership row). */ + onAllocationFailure: (stage: SessionLedgerAllocationFailureStage) => Promise; + /** The DO RPC threw; the commit outcome is unknown. */ + onTransportFailure: () => Promise; + /** The DO explicitly rejected registration or the initial admission. */ + onExplicitRejection: ( + failure: Extract< + SessionEstablishmentFailure, + { stage: 'registration' } | { stage: 'initial_admission' } + > + ) => Promise; + /** The DO confirmed registration and initial message admission. */ + onSuccess: (result: StartedSessionResult) => Promise; +}; + +/** Result returned to the prepare handler for ledger-guarded creates. */ +export type LedgerSessionCreateResult = { + cloudAgentSessionId: string; + kiloSessionId: string; + replayed?: boolean; +}; + +export type SessionLedgerCreateOptions = { + billingOrigin?: string; + operationKey: string; + /** Epoch ms when the user intent started, used for the outbox duration. */ + startedAt: number; +}; + +/** Lease for the `admitted` create claim (retry window). */ +const SESSION_CREATE_LEDGER_LEASE_SECONDS = 120; + +/** Carries the allocation failure stage so the ledger can settle it. */ +class SessionAllocationStageError extends Error { + readonly stage: SessionLedgerAllocationFailureStage; + + constructor(stage: SessionLedgerAllocationFailureStage, cause: unknown) { + super('Session allocation failed', { cause }); + this.name = 'SessionAllocationStageError'; + this.stage = stage; + } +} + +/** Re-throws the original error on the legacy path and the tagged error on the ledger path. */ +function rethrowAllocationFailure( + ledger: SessionCreationLedgerHooks | undefined, + stage: SessionLedgerAllocationFailureStage, + error: unknown +): never { + if (ledger) { + throw new SessionAllocationStageError(stage, error); + } + throw error; +} + +/** tRPC CONFLICT with the stable `creation_in_progress` message (plan P1-A-08b). */ +function creationInProgressError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: 'creation_in_progress' }); +} + +/** + * Best-effort ledger write: a failure is logged and never masks the primary + * creation outcome. The row then stays `admitted`/`reconcile_pending` and the + * same-key retry ladder recovers it. + */ +async function bestEffortLedgerWrite(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Failed to write session create operation ledger row'); + } +} + +/** Resolves the analytics identity channel (user email); falls back to the user id. */ +async function resolveSessionCreateDistinctId(db: WorkerDb, userId: string): Promise { + try { + const [user] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)) + .limit(1); + return user?.email ?? userId; + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .warn('Failed to resolve user email for session create outbox event'); + return userId; + } +} + +type SessionCreateSettledOutcome = 'completed' | 'failed'; + +function sessionCreateSettledOutboxEvent(params: { + distinctId: string; + outcome: SessionCreateSettledOutcome; + admission: 'new' | 'takeover'; + failureStage?: SessionLedgerFailureStage; + startedAt: number; + inOrganization: boolean; +}): OutboxEventInput { + return { + eventName: 'session_create_settled', + distinctId: params.distinctId, + properties: { + source: 'server', + surface: 'session', + phase: 'terminal', + creation_target: 'cloud', + outcome: params.outcome, + admission: params.admission, + ...(params.failureStage ? { failure_stage: params.failureStage } : {}), + duration_ms: Math.max(0, Date.now() - params.startedAt), + in_organization: params.inOrganization, + }, + }; +} + function initialAdmissionFailure( result: Extract ): Extract { @@ -141,7 +295,8 @@ async function recordPostSetupFailure(record: () => Promise): Promise { const sessionService = new SessionService(); const initialTurn = acceptInitialTurn(input.initialTurn); @@ -149,10 +304,26 @@ async function allocateNewSession( const kiloSessionId = generateKiloSessionId(); const createdOnPlatform = input.options?.createdOnPlatform ?? 'cloud-agent'; - await createCloudAgentSessionReport( - { cloudAgentSessionId, kiloSessionId, initialMessageId: initialTurn.messageId }, - ctx.env - ); + try { + if (ledger) { + // Record progress immediately after ID generation (plan P1-A-08b step 3): + // the ladder treats missing IDs as "nothing external happened". A failure + // here still fails the create at the report allocation stage and settles + // the admitted row so the client gets a terminal result. + await recordOperationProgress(ledger.db, ledger.rowId, { + cloudAgentSessionId, + kiloSessionId, + initialMessageId: initialTurn.messageId, + }); + } + + await createCloudAgentSessionReport( + { cloudAgentSessionId, kiloSessionId, initialMessageId: initialTurn.messageId }, + ctx.env + ); + } catch (error) { + rethrowAllocationFailure(ledger, 'report', error); + } const orgId = input.options?.kilocodeOrganizationId; const devcontainerRequested = input.runtime?.devcontainer === true; @@ -203,17 +374,23 @@ async function allocateNewSession( sandboxProvider = 'cloudflare'; } } catch (error) { - await recordCloudAgentSessionFailure( - { - cloudAgentSessionId, - failure: { stage: 'sandbox_identity', code: 'sandbox_id_derivation_failed' }, - }, - ctx.env + await recordPostSetupFailure(() => + recordCloudAgentSessionFailure( + { + cloudAgentSessionId, + failure: { stage: 'sandbox_identity', code: 'sandbox_id_derivation_failed' }, + }, + ctx.env + ) ); - throw error; + rethrowAllocationFailure(ledger, 'sandbox', error); } - await recordCloudAgentSandboxIdentity({ cloudAgentSessionId, sandboxId }, ctx.env); + try { + await recordCloudAgentSandboxIdentity({ cloudAgentSessionId, sandboxId }, ctx.env); + } catch (error) { + rethrowAllocationFailure(ledger, 'sandbox', error); + } logger.setTags({ cloudAgentSessionId, @@ -236,14 +413,16 @@ async function allocateNewSession( defaultTitle ); } catch (error) { - await recordCloudAgentSessionFailure( - { - cloudAgentSessionId, - failure: { stage: 'transport', code: 'do_rpc_outcome_unknown' }, - }, - ctx.env + await recordPostSetupFailure(() => + recordCloudAgentSessionFailure( + { + cloudAgentSessionId, + failure: { stage: 'transport', code: 'do_rpc_outcome_unknown' }, + }, + ctx.env + ) ); - throw error; + rethrowAllocationFailure(ledger, 'ownership_row', error); } return { @@ -380,9 +559,19 @@ export async function registerNewSession( export async function startNewSession( input: SessionRegistrationInput, ctx: SessionRegistrationContext, - options?: { billingOrigin?: string } + options?: { billingOrigin?: string }, + ledger?: SessionCreationLedgerHooks ): Promise { - const allocation = await allocateNewSession(input, ctx, options); + let allocation: NewSessionAllocation; + try { + allocation = await allocateNewSession(input, ctx, options, ledger); + } catch (error) { + if (ledger && error instanceof SessionAllocationStageError) { + await ledger.onAllocationFailure(error.stage); + throw error.cause; + } + throw error; + } const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName( `${ctx.userId}:${allocation.cloudAgentSessionId}` ); @@ -410,6 +599,9 @@ export async function startNewSession( ctx.env ) ); + if (ledger) { + await ledger.onTransportFailure(); + } throw error; } @@ -428,15 +620,350 @@ export async function startNewSession( logger .withFields({ error: admission.error, resultCode: admission.code }) .error('Failed to register session and admit initial turn in DO'); + if (ledger) { + await ledger.onExplicitRejection(failure); + } throwAdmissionError(admission); } logger.info('Session registered with initial message admitted'); - return { + const result: StartedSessionResult = { cloudAgentSessionId: allocation.cloudAgentSessionId, kiloSessionId: allocation.kiloSessionId, sandboxId: allocation.sandboxId, sandboxProvider: allocation.sandboxProvider, admission, }; + if (ledger) { + await ledger.onSuccess(result); + } + return result; +} + +// ----- ledger-guarded session creation (plan P1-A-08b step 3) ----------------- + +/** + * Creates a session under the operation ledger. Admit only when the caller + * (the prepare handler) has already gated on `operationKey` present AND + * effective `autoInitiate` true. + * + * Admission outcomes: + * - `admitted`: run the create effect and settle completed/failed, or mark + * reconcile-pending on an unknown transport outcome. + * - `duplicate_settled`: replay the canonical result with `replayed: true`. + * - `duplicate_in_flight`: `CONFLICT` `creation_in_progress`. + * - `takeover` / `duplicate_reconcile_pending`: reconcile before any effect. + */ +export async function createSessionWithLedger( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + options: SessionLedgerCreateOptions +): Promise { + const db = getPgDb(ctx.env); + const admission = await admitOperation(db, { + userId: ctx.userId, + orgId: input.options?.kilocodeOrganizationId, + domain: 'session', + intent: 'create_cloud', + operationKey: options.operationKey, + taxonomy: 'safe-retry', + leaseSeconds: SESSION_CREATE_LEDGER_LEASE_SECONDS, + }); + + switch (admission.admission) { + case 'admitted': + return executeLedgerCreate(input, ctx, options, db, admission.row, 'new'); + case 'duplicate_settled': + return replaySettledCreate(admission.row); + case 'duplicate_in_flight': + throw creationInProgressError(); + case 'takeover': + case 'duplicate_reconcile_pending': + return reconcileLedgerCreate(input, ctx, options, db, admission.row); + } +} + +function replaySettledCreate(row: OperationLedgerRow): LedgerSessionCreateResult { + // Only a `completed` settle may replay a successful create. Failed, no_op, + // interrupted, and superseded terminal rows must surface the typed + // non-retryable failure even when progress recorded canonical IDs before the + // failure: progress IDs prove allocation, never success. + if (row.status !== 'completed') { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + } + const canonical = row.canonical_result ?? {}; + const cloudAgentSessionId = + typeof canonical.cloudAgentSessionId === 'string' ? canonical.cloudAgentSessionId : undefined; + const kiloSessionId = + typeof canonical.kiloSessionId === 'string' ? canonical.kiloSessionId : undefined; + if (!cloudAgentSessionId || !kiloSessionId) { + // A completed settle without canonical IDs has no session to replay. Treat + // the retry as a fresh intent by surfacing a non-retryable typed rejection + // so the client clears the key. + throw new TRPCError({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + } + return { cloudAgentSessionId, kiloSessionId, replayed: true }; +} + +/** + * Runs the create effect under an already-admitted row and settles it: + * completed after registration + initial admission, failed on explicit + * rejection or pre-DO allocation failure, reconcile-pending on an unknown + * transport outcome. On `takeover`/reconcile rows the settle uses the + * `takeover` admission kind. + */ +async function executeLedgerCreate( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + options: SessionLedgerCreateOptions, + db: WorkerDb, + row: OperationLedgerRow, + admissionKind: 'new' | 'takeover' +): Promise { + const distinctId = await resolveSessionCreateDistinctId(db, ctx.userId); + const inOrganization = input.options?.kilocodeOrganizationId != null; + + const hooks: SessionCreationLedgerHooks = { + db, + rowId: row.id, + onAllocationFailure: stage => + bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: row.id, + status: 'failed', + outcomeCode: stage, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'failed', + admission: admissionKind, + failureStage: stage, + startedAt: options.startedAt, + inOrganization, + }), + }) + ), + onTransportFailure: () => + bestEffortLedgerWrite(() => markReconcilePending(db, { rowId: row.id })), + onExplicitRejection: failure => + bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: row.id, + status: 'failed', + outcomeCode: failure.code, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'failed', + admission: admissionKind, + failureStage: failure.stage === 'registration' ? 'registration' : 'initial_admission', + startedAt: options.startedAt, + inOrganization, + }), + }) + ), + onSuccess: result => + bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { + cloudAgentSessionId: result.cloudAgentSessionId, + kiloSessionId: result.kiloSessionId, + }, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'completed', + admission: admissionKind, + startedAt: options.startedAt, + inOrganization, + }), + }) + ), + }; + + const result = await startNewSession(input, ctx, { billingOrigin: options.billingOrigin }, hooks); + return { + cloudAgentSessionId: result.cloudAgentSessionId, + kiloSessionId: result.kiloSessionId, + }; +} + +/** Looks up the `cli_sessions_v2` ownership row written before the DO call. */ +async function findCliSessionOwnershipRow( + db: WorkerDb, + userId: string, + kiloSessionId: string +): Promise<{ sessionId: string } | null> { + const [row] = await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and(eq(cli_sessions_v2.kilo_user_id, userId), eq(cli_sessions_v2.session_id, kiloSessionId)) + ) + .limit(1); + return row ?? null; +} + +/** + * Reads DO metadata with the standard retry wrapper. A transport failure keeps + * the row reconcile-pending (`CONFLICT creation_in_progress`) because the DO + * state is unknown; it must never settle the row. + */ +async function readSessionMetadata( + ctx: SessionRegistrationContext, + doId: DurableObjectId +): Promise { + try { + return await withDORetry( + () => ctx.env.CLOUD_AGENT_SESSION.get(doId), + s => s.getMetadata(), + 'getMetadata' + ); + } catch { + throw creationInProgressError(); + } +} + +/** + * Takeover / reconcile-pending reconciliation ladder (plan P1-A-08b step 3): + * a. No progress IDs → nothing external happened → fresh create under the row. + * b. IDs recorded, ownership row absent → the DO never registered → fresh + * create under the row. + * c. Ownership row present, DO metadata absent → the DO never committed + * registration → `onlyIfEmpty` delete of the stale ownership row, then a + * fresh create; if the delete refused (row not empty), the session is + * live only when the DO authoritatively re-proves registration, and the + * ladder falls through to (d) on that re-read. + * d. Metadata present → completion requires the recorded `initialMessageId` + * to be admitted. Admitted → settle completed (+ outbox) and replay. + * Not admitted / not determinable → `CONFLICT` `creation_in_progress`. + */ +async function reconcileLedgerCreate( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + options: SessionLedgerCreateOptions, + db: WorkerDb, + row: OperationLedgerRow +): Promise { + const canonical = row.canonical_result ?? {}; + const cloudAgentSessionId = + typeof canonical.cloudAgentSessionId === 'string' ? canonical.cloudAgentSessionId : undefined; + const kiloSessionId = + typeof canonical.kiloSessionId === 'string' ? canonical.kiloSessionId : undefined; + + // (a) No progress IDs recorded → nothing external happened. + if (!cloudAgentSessionId || !kiloSessionId) { + return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); + } + + // (b) Ownership row lookup by kiloSessionId. + const ownership = await findCliSessionOwnershipRow(db, ctx.userId, kiloSessionId); + if (!ownership) { + return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); + } + + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName(`${ctx.userId}:${cloudAgentSessionId}`); + + // (c) Ownership present → read the DO state. + const metadata = await readSessionMetadata(ctx, doId); + + if (!metadata) { + const sessionService = new SessionService(); + try { + await sessionService.deleteCliSessionViaSessionIngest(kiloSessionId, ctx.userId, ctx.env, { + onlyIfEmpty: true, + }); + } catch { + throw creationInProgressError(); + } + const afterDelete = await findCliSessionOwnershipRow(db, ctx.userId, kiloSessionId); + if (afterDelete) { + // The delete refused: the row has real content. Completion still requires + // authoritative DO registration: `getMessageResult` reports + // `session-not-found` whenever metadata is absent, so admission can only + // prove a live session after the DO re-proves registration. A second + // absent read means the session is not live → stay reconcile-pending. + const registered = await readSessionMetadata(ctx, doId); + if (!registered) { + throw creationInProgressError(); + } + return confirmInitialMessageAdmitted( + input, + ctx, + options, + db, + row, + cloudAgentSessionId, + kiloSessionId, + canonical.initialMessageId + ); + } + // Stale ownership row removed → re-execute with fresh IDs under the row. + return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); + } + + // (d) Metadata present → completion requires the recorded initialMessageId. + return confirmInitialMessageAdmitted( + input, + ctx, + options, + db, + row, + cloudAgentSessionId, + kiloSessionId, + canonical.initialMessageId + ); +} + +async function confirmInitialMessageAdmitted( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + options: SessionLedgerCreateOptions, + db: WorkerDb, + row: OperationLedgerRow, + cloudAgentSessionId: string, + kiloSessionId: string, + initialMessageId: unknown +): Promise { + if (typeof initialMessageId !== 'string' || initialMessageId.length === 0) { + throw creationInProgressError(); + } + + const doId = ctx.env.CLOUD_AGENT_SESSION.idFromName(`${ctx.userId}:${cloudAgentSessionId}`); + let messageResult: MessageResultRPCResponse; + try { + messageResult = await withDORetry< + DurableObjectStub, + MessageResultRPCResponse + >( + () => ctx.env.CLOUD_AGENT_SESSION.get(doId), + stub => stub.getMessageResult(initialMessageId), + 'getMessageResult' + ); + } catch { + throw creationInProgressError(); + } + + if (messageResult.type !== 'found') { + // Not admitted or not determinable. + throw creationInProgressError(); + } + + const distinctId = await resolveSessionCreateDistinctId(db, ctx.userId); + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { cloudAgentSessionId, kiloSessionId }, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'completed', + admission: 'takeover', + startedAt: options.startedAt, + inOrganization: input.options?.kilocodeOrganizationId != null, + }), + }) + ); + return { cloudAgentSessionId, kiloSessionId, replayed: true }; } diff --git a/services/cloud-agent-next/src/session/session-requests.ts b/services/cloud-agent-next/src/session/session-requests.ts index fcbf925186..e4069c42fb 100644 --- a/services/cloud-agent-next/src/session/session-requests.ts +++ b/services/cloud-agent-next/src/session/session-requests.ts @@ -62,5 +62,12 @@ export type SessionCreateRequest = { kilocodeOrganizationId?: string; createdOnPlatform?: string; shallow?: boolean; + /** + * Client-generated UUID, stable across retries of one user intent. The + * handler admits the create into the operation ledger only when this is + * present AND the effective `autoInitiate` is true; otherwise the key is + * ignored and the legacy split-flow behavior is preserved. + */ + operationKey?: string; }; }; From 2b91bdf70c7f33810631d2d2a69199a48b0cf8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 04:10:58 +0200 Subject: [PATCH 06/56] fix(db): record progress during session reconciliation --- .../operation-ledger.integration.test.ts | 45 +++++++++ packages/db/src/operation-ledger.ts | 22 +++-- .../src/session/session-prepare.test.ts | 92 +++++++++++++++++++ 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts index 1f05a8feb6..c6bfb41443 100644 --- a/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts +++ b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts @@ -322,6 +322,51 @@ describe('operation ledger (integration)', () => { expect(lateReconcile?.status).toBe('completed'); }); + it('records fresh allocation progress on a reconcile_pending row', async () => { + const admitted = await admitSession('reconcile-progress-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + await markReconcilePending(db, { rowId }); + const replay = await admitSession('reconcile-progress-user', admitted.row.operation_key); + expect(replay.admission).toBe('duplicate_reconcile_pending'); + + // A fresh takeover allocation under the reconcile_pending row must persist + // its new IDs: the next retry reconciles them instead of allocating again. + const progress = await recordOperationProgress(db, rowId, { + cloudAgentSessionId: 'agent_allocated', + kiloSessionId: 'ses_allocated', + }); + expect(progress?.status).toBe('reconcile_pending'); + expect(progress?.canonical_result).toMatchObject({ + cloudAgentSessionId: 'agent_allocated', + kiloSessionId: 'ses_allocated', + }); + + // The 4096 serialized-byte bound still applies to progress on the row. + const oversized: Record = { pad: 'x'.repeat(5000) }; + await expect(recordOperationProgress(db, rowId, oversized)).rejects.toBeInstanceOf( + CanonicalResultTooLargeError + ); + + const [row] = await db.select().from(operation_ledgers).where(eq(operation_ledgers.id, rowId)); + expect(row?.status).toBe('reconcile_pending'); + expect(row?.canonical_result).toMatchObject({ + cloudAgentSessionId: 'agent_allocated', + kiloSessionId: 'ses_allocated', + }); + expect(row?.canonical_result).not.toHaveProperty('pad'); + + // The next same-key admit reports the recorded IDs for reconciliation. + const retry = await admitSession('reconcile-progress-user', admitted.row.operation_key); + expect(retry.admission).toBe('duplicate_reconcile_pending'); + if (retry.admission !== 'duplicate_reconcile_pending') return; + expect(retry.row.canonical_result).toMatchObject({ + cloudAgentSessionId: 'agent_allocated', + kiloSessionId: 'ses_allocated', + }); + }); + it('merges recordOperationProgress into canonical_result while admitted', async () => { const admitted = await admitSession('progress-user'); if (admitted.admission !== 'admitted') return; diff --git a/packages/db/src/operation-ledger.ts b/packages/db/src/operation-ledger.ts index 023a7e60eb..16ba77d140 100644 --- a/packages/db/src/operation-ledger.ts +++ b/packages/db/src/operation-ledger.ts @@ -326,11 +326,14 @@ async function evaluateExistingRow( // ----- progress and provider reference ------------------------------------------ /** - * Merges allocated identifiers into `canonical_result` while the row stays - * `admitted`. Returns the updated row, or null when the row is missing or no - * longer admitted (the CAS did not match). The merged result is bounded at - * `MAX_CANONICAL_RESULT_BYTES` serialized bytes: an oversized merge throws - * `CanonicalResultTooLargeError` and leaves the row unchanged. + * Merges allocated identifiers into `canonical_result` while the row is + * non-terminal (`admitted` or `reconcile_pending`). A fresh takeover + * allocation under a reconcile-pending row must record its new IDs so the next + * same-key retry reconciles them instead of allocating a third time. Returns + * the updated row, or null when the row is missing or terminal (the CAS did + * not match). The merged result is bounded at `MAX_CANONICAL_RESULT_BYTES` + * serialized bytes: an oversized merge throws `CanonicalResultTooLargeError` + * and leaves the row unchanged. */ export async function recordOperationProgress( database: LedgerDatabase, @@ -344,7 +347,7 @@ export async function recordOperationProgress( .where(eq(operation_ledgers.id, rowId)) .for('update'); - if (!row || row.status !== 'admitted') { + if (!row || isTerminalOperationStatus(row.status)) { return null; } @@ -358,7 +361,12 @@ export async function recordOperationProgress( const [updated] = await tx .update(operation_ledgers) .set({ canonical_result: merged }) - .where(and(eq(operation_ledgers.id, row.id), eq(operation_ledgers.status, 'admitted'))) + .where( + and( + eq(operation_ledgers.id, row.id), + inArray(operation_ledgers.status, OPERATION_NON_TERMINAL_STATUSES) + ) + ) .returning(); return updated ?? null; }); diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 3fb79fd481..ad29f1f15b 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -936,4 +936,96 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { replayed: true, }); }); + + it('regression: fresh allocation under reconcile_pending persists IDs so the next retry reconciles instead of allocating a third session', async () => { + // The row is already reconcile-pending from an earlier unknown-transport + // outcome whose recorded IDs are stale (the ownership row is gone). The + // ladder must allocate fresh IDs and record them even though the row is + // `reconcile_pending`, not `admitted`. + admitOperationMock + .mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: { + cloudAgentSessionId: 'agent_stale_a', + kiloSessionId: 'ses_stale_a', + initialMessageId: 'msg_stale_a', + }, + }), + }) + .mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: canonicalIds, + }), + }); + + // Attempt 1: stale-A ownership lookup is absent (fresh create), then the + // distinct-id lookup for the fresh create. Attempt 2: fresh-B ownership + // lookup is present, then the distinct-id lookup for the settle. + getPgDbMock.mockReturnValue( + makeDb([ + [], + [{ email: 'test@example.com' }], + [{ sessionId: KILO_SESSION_ID }], + [{ email: 'test@example.com' }], + ]) + ); + + // Unknown transport on the fresh allocation's DO call; any hypothetical + // third allocation would hit the default stub and fail the call-count + // assertions below. + const doStub = makeDoStub({ + createSessionWithInitialAdmission: vi.fn().mockRejectedValueOnce(new Error('rpc timed out')), + }); + const ctx = makeContext(doStub); + + // First retry: fresh allocation, then unknown transport. + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toThrow('rpc timed out'); + + // The fresh allocation recorded its new IDs on the reconcile_pending row + // and the unknown transport kept the row reconcile-pending. + expect(recordOperationProgressMock).toHaveBeenCalledWith(expect.any(Object), ROW_ID, { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + initialMessageId: INITIAL_MESSAGE_ID, + }); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { + rowId: ROW_ID, + }); + + // Second retry: reconciles the freshly recorded IDs; no third allocation. + const result = await createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + { operationKey: OPERATION_KEY, startedAt: 1_700_000_000_000 } + ); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.getMessageResult).toHaveBeenCalledWith(INITIAL_MESSAGE_ID); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }) + ); + expect(result).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + }); }); From 08155b7d6aad11128b168b43b6a73d933a358fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 04:30:36 +0200 Subject: [PATCH 07/56] fix(db): serialize ledger reconciliation by lease --- .../operation-ledger.integration.test.ts | 67 ++++++++++++++++-- packages/db/src/operation-ledger.ts | 50 +++++++++++-- .../src/session/session-prepare.test.ts | 70 +++++++++++++++++++ .../src/session/session-registration.ts | 3 + 4 files changed, 178 insertions(+), 12 deletions(-) diff --git a/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts index c6bfb41443..4a6ccc3729 100644 --- a/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts +++ b/apps/web/src/lib/analytics-outbox/operation-ledger.integration.test.ts @@ -51,7 +51,7 @@ async function admitSession(userId = 'ledger-user', operationKey: string = rando return admitOperation(db, { userId, domain: SESSION_DOMAIN, - intent: 'create', + intent: 'create_cloud', operationKey, taxonomy: 'safe-retry', leaseSeconds: 60, @@ -92,7 +92,7 @@ describe('operation ledger (integration)', () => { admitOperation(db, { userId: 'concurrent-user', domain: SESSION_DOMAIN, - intent: 'create', + intent: 'create_cloud', operationKey: 'concurrent-key', taxonomy: 'safe-retry', leaseSeconds: 60, @@ -322,6 +322,62 @@ describe('operation ledger (integration)', () => { expect(lateReconcile?.status).toBe('completed'); }); + it('serializes concurrent reconcile retries behind exactly one lease claim', async () => { + const admitted = await admitSession('concurrent-reconcile-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + // The transition makes the reconciliation lease immediately claimable. + await markReconcilePending(db, { rowId }); + + const results = await Promise.all( + Array.from({ length: 8 }, () => + admitSession('concurrent-reconcile-user', admitted.row.operation_key) + ) + ); + + // Exactly one retry holds the reconciliation lease and may run the effect. + expect(results.filter(r => r.admission === 'duplicate_reconcile_pending')).toHaveLength(1); + // Every other retry sees the live reconciliation lease and must not run it. + expect(results.filter(r => r.admission === 'duplicate_reconcile_in_progress')).toHaveLength( + results.length - 1 + ); + + const claim = results.find(r => r.admission === 'duplicate_reconcile_pending'); + expect(claim).toBeDefined(); + expect(new Date(claim?.row.lease_expires_at ?? 0).getTime()).toBeGreaterThan(Date.now()); + }); + + it('takes over an expired reconciliation lease after a crashed reconciler', async () => { + const admitted = await admitSession('reconcile-takeover-user'); + if (admitted.admission !== 'admitted') return; + const rowId = admitted.row.id; + + await markReconcilePending(db, { rowId }); + + // The first retry atomically claims the claimable lease and reconciles. + const claim = await admitSession('reconcile-takeover-user', admitted.row.operation_key); + expect(claim.admission).toBe('duplicate_reconcile_pending'); + if (claim.admission !== 'duplicate_reconcile_pending') return; + + // While the reconciler holds the lease, a retry reports in-progress. + const inProgress = await admitSession('reconcile-takeover-user', admitted.row.operation_key); + expect(inProgress.admission).toBe('duplicate_reconcile_in_progress'); + + // The reconciler crashes; its lease expires. + await db + .update(operation_ledgers) + .set({ lease_expires_at: '2020-01-01T00:00:00.000Z' }) + .where(eq(operation_ledgers.id, rowId)); + + // A later retry takes over the expired reconciliation lease and may reconcile. + const takeover = await admitSession('reconcile-takeover-user', admitted.row.operation_key); + expect(takeover.admission).toBe('duplicate_reconcile_pending'); + if (takeover.admission !== 'duplicate_reconcile_pending') return; + expect(takeover.row.id).toBe(rowId); + expect(new Date(takeover.row.lease_expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + it('records fresh allocation progress on a reconcile_pending row', async () => { const admitted = await admitSession('reconcile-progress-user'); if (admitted.admission !== 'admitted') return; @@ -357,10 +413,11 @@ describe('operation ledger (integration)', () => { }); expect(row?.canonical_result).not.toHaveProperty('pad'); - // The next same-key admit reports the recorded IDs for reconciliation. + // A retry within the live reconciliation lease reports in-progress; the + // row still carries the recorded IDs for the later reconciliation. const retry = await admitSession('reconcile-progress-user', admitted.row.operation_key); - expect(retry.admission).toBe('duplicate_reconcile_pending'); - if (retry.admission !== 'duplicate_reconcile_pending') return; + expect(retry.admission).toBe('duplicate_reconcile_in_progress'); + if (retry.admission !== 'duplicate_reconcile_in_progress') return; expect(retry.row.canonical_result).toMatchObject({ cloudAgentSessionId: 'agent_allocated', kiloSessionId: 'ses_allocated', diff --git a/packages/db/src/operation-ledger.ts b/packages/db/src/operation-ledger.ts index 16ba77d140..27a492aef0 100644 --- a/packages/db/src/operation-ledger.ts +++ b/packages/db/src/operation-ledger.ts @@ -3,7 +3,11 @@ * * One row per `(kilo_user_id, domain, operation_key)` identity. Admission is * concurrent-safe: exactly one caller admits, the rest receive the typed - * duplicate/takeover outcome and never re-execute the effect. Terminal settles + * duplicate/takeover outcome and never re-execute the effect. Reconciliation + * of a `reconcile_pending` row is serialized by the same `lease_expires_at` + * column: exactly one retry atomically claims the lease and reconciles, the + * rest receive `duplicate_reconcile_in_progress`, and an expired + * reconciliation lease can be claimed by a later retry. Terminal settles * are CAS from `admitted | reconcile_pending`; a second settle is a no-op. * The analytics outbox row is written in the same transaction as the settle, * so settle-plus-outbox is atomic, and ONLY the helpers in this file insert @@ -131,7 +135,8 @@ export type AdmitOperationResult = | { admission: 'duplicate_settled'; row: OperationLedgerRow } | { admission: 'duplicate_in_flight'; row: OperationLedgerRow } | { admission: 'takeover'; row: OperationLedgerRow } - | { admission: 'duplicate_reconcile_pending'; row: OperationLedgerRow }; + | { admission: 'duplicate_reconcile_pending'; row: OperationLedgerRow } + | { admission: 'duplicate_reconcile_in_progress'; row: OperationLedgerRow }; /** Terminal outbox event input, correlated by event name. */ export type OutboxEventInput = { @@ -203,7 +208,10 @@ function admissionInsertValues(input: AdmitOperationInput, now: Date): NewOperat * row (`expires_at` past, any status) is deleted and re-inserted in one * transaction. A live-lease `admitted` row is `duplicate_in_flight`; an * expired-lease `admitted` row is a compare-and-set `takeover` that renews - * the lease. + * the lease. A `reconcile_pending` row with an expired/claimable lease is + * claimed by exactly one retry (`duplicate_reconcile_pending`); concurrent + * retries during the live reconciliation lease receive + * `duplicate_reconcile_in_progress` and must not run the effect. */ export async function admitOperation( database: LedgerDatabase, @@ -317,7 +325,28 @@ async function evaluateExistingRow( } if (row.status === 'reconcile_pending') { - return { admission: 'duplicate_reconcile_pending', row }; + // A live reconciliation lease means another retry already claimed it and + // is reconciling; the caller must surface an in-progress response and must + // not run the effect. + if (new Date(row.lease_expires_at).getTime() > now.getTime()) { + return { admission: 'duplicate_reconcile_in_progress', row }; + } + // Compare-and-set lease claim: reconcile only while the lease is still + // claimable. The row lock serializes concurrent claims; the CAS guards a + // claim renewed between the read and the update. + const claimedLease = new Date(now.getTime() + input.leaseSeconds * 1000).toISOString(); + const [claimed] = await tx + .update(operation_ledgers) + .set({ lease_expires_at: claimedLease }) + .where( + and( + eq(operation_ledgers.id, row.id), + eq(operation_ledgers.status, 'reconcile_pending'), + sql`${operation_ledgers.lease_expires_at} <= ${now.toISOString()}::timestamptz` + ) + ) + .returning(); + return { admission: 'duplicate_reconcile_pending', row: claimed ?? row }; } return { admission: 'duplicate_settled', row }; @@ -469,8 +498,11 @@ async function settleOperationInTransaction( /** * Marks a row `reconcile_pending`, CAS from `admitted`. May emit an * `outcome: 'ambiguous'` outbox event (a ledger state change, not an HTTP - * receipt). Returns the stored row when the CAS did not match (missing or not - * `admitted`). + * receipt). The transition also makes the reconciliation lease immediately + * claimable (`lease_expires_at` set to now), so the next same-key retry can + * atomically claim it and reconcile instead of waiting out the original + * admitted lease. Returns the stored row when the CAS did not match (missing + * or not `admitted`). */ export async function markReconcilePending( database: LedgerDatabase, @@ -495,9 +527,13 @@ export async function markReconcilePending( await insertOutboxEvent(tx, { rowId: row.id, event: input.outboxEvent }); } + const now = new Date(); const [updated] = await tx .update(operation_ledgers) - .set({ status: 'reconcile_pending' }) + .set({ + status: 'reconcile_pending', + lease_expires_at: now.toISOString(), + }) .where(and(eq(operation_ledgers.id, row.id), eq(operation_ledgers.status, 'admitted'))) .returning(); return updated ?? row; diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index ad29f1f15b..4023529f04 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -638,6 +638,29 @@ describe('createSessionWithLedger admission ladder', () => { expect(settleOperationMock).not.toHaveBeenCalled(); }); + + it('returns CONFLICT creation_in_progress when another retry holds the reconciliation lease', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_in_progress', + row: makeLedgerRow({ status: 'reconcile_pending' }), + }); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + + // The in-progress retry must not run the effect, reconcile, or settle. + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.getMessageResult).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(markReconcilePendingMock).not.toHaveBeenCalled(); + }); }); describe('createSessionWithLedger takeover reconciliation ladder', () => { @@ -937,6 +960,53 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { }); }); + it('regression: concurrent reconcile retries run the effect once and report creation_in_progress to the rest', async () => { + // The first retry atomically claimed the reconciliation lease and may + // reconcile; every concurrent retry sees the live lease and must not. + admitOperationMock + .mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }) + .mockResolvedValue({ + admission: 'duplicate_reconcile_in_progress', + row: makeLedgerRow({ status: 'reconcile_pending' }), + }); + // Claim winner: ownership lookup, then the distinct-id lookup for the settle. + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [{ email: 'test@example.com' }]]) + ); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + const [winner, loser] = await Promise.all([ + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ), + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ).then( + () => null, + (error: unknown) => error + ), + ]); + + expect(winner).toEqual({ + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(loser).toMatchObject({ code: 'CONFLICT', message: 'creation_in_progress' }); + // Exactly one retry reconciled: the ladder read the DO state once. + expect(doStub.getMessageResult).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledTimes(1); + }); + it('regression: fresh allocation under reconcile_pending persists IDs so the next retry reconciles instead of allocating a third session', async () => { // The row is already reconcile-pending from an earlier unknown-transport // outcome whose recorded IDs are stale (the ownership row is gone). The diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 5911301dd5..e8a414a589 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -652,6 +652,8 @@ export async function startNewSession( * reconcile-pending on an unknown transport outcome. * - `duplicate_settled`: replay the canonical result with `replayed: true`. * - `duplicate_in_flight`: `CONFLICT` `creation_in_progress`. + * - `duplicate_reconcile_in_progress`: another retry holds the reconciliation + * lease; `CONFLICT` `creation_in_progress`. * - `takeover` / `duplicate_reconcile_pending`: reconcile before any effect. */ export async function createSessionWithLedger( @@ -676,6 +678,7 @@ export async function createSessionWithLedger( case 'duplicate_settled': return replaySettledCreate(admission.row); case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': throw creationInProgressError(); case 'takeover': case 'duplicate_reconcile_pending': From 55a4160133179438726c95a12ddbf8b36d9a017b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 06:54:52 +0200 Subject: [PATCH 08/56] feat(pr-review): make mutations retry safe --- .../pr-review/discussion/reply-input.tsx | 11 + .../pr-review/merge/pr-merge-sheet.test.tsx | 7 + .../pr-review/merge/pr-merge-sheet.tsx | 11 + .../use-review-discussion-mutations.test.ts | 211 +++ .../use-review-discussion-mutations.ts | 65 +- .../pr-operation-ledger.mounted.test.tsx | 126 ++ .../merge/pr-operation-ledger.test.ts | 155 +++ .../pr-review/merge/pr-operation-ledger.ts | 133 ++ .../merge/use-pr-merge-mutations.test.ts | 128 +- .../pr-review/merge/use-pr-merge-mutations.ts | 53 +- .../pr-review/mutation-error-display.test.ts | 57 +- .../lib/pr-review/mutation-error-display.ts | 22 + .../pr-review/use-pr-review-mutations.test.ts | 366 ++++++ .../lib/pr-review/use-pr-review-mutations.ts | 135 +- .../routers/github-pr-review-router.test.ts | 882 +++++++++++++ .../src/routers/github-pr-review-router.ts | 1165 +++++++++++++++-- 16 files changed, 3359 insertions(+), 168 deletions(-) create mode 100644 apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.mounted.test.tsx create mode 100644 apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts create mode 100644 apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx index b6d35eed66..46208e5bf0 100644 --- a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx +++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx @@ -11,6 +11,10 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { + isPrOperationPersistenceFailed, + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, +} from '@/lib/pr-review/merge/pr-operation-ledger'; const REPLY_PLACEHOLDER = 'Reply…'; @@ -37,6 +41,13 @@ export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly { if (reply.error) { + // The ledger persistence-failure marker is retry-blocking: the row never + // became `reconcile_pending`, so the same key must not be retried. + if (isPrOperationPersistenceFailed(reply.error)) { + setInlineError(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + setInlineErrorKind('bad-request'); + return; + } const classification = classifyPrReviewMutationError(reply.error); if (classification.kind === 'bad-request') { setInlineError("This reply can't be posted. The thread may have changed."); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx index 7e5be04828..355231c585 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.test.tsx @@ -68,6 +68,13 @@ vi.mock('expo-haptics', () => ({ NotificationFeedbackType: { Success: 'Success' }, })); +// `pr-merge-sheet` imports the ledger helpers, which import `expo-crypto` +// (and transitively expo-modules-core). Mock it so this suite stays +// node-only, same as the other ledger pure tests. +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used-in-pure-tests', +})); + vi.mock('sonner-native', () => ({ toast: { error: vi.fn() }, })); diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index 07d963e3b8..9704bacf84 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -27,6 +27,10 @@ import { useMergePullRequestMutation, } from '@/lib/pr-review/merge/use-pr-merge-mutations'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { + isPrOperationPersistenceFailed, + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, +} from '@/lib/pr-review/merge/pr-operation-ledger'; import { applyMergeSuccessEffects } from '@/lib/pr-review/merge/merge-success-effects'; import { defaultMergeMethodOptionFor, @@ -149,6 +153,13 @@ export function PrMergeSheet(props: PrMergeSheetProps) { useEffect(() => { if (lastError) { + // The ledger persistence-failure marker is retry-blocking: the row never + // became `reconcile_pending`, so the same key must not be retried. + if (isPrOperationPersistenceFailed(lastError)) { + setInlineError(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + setInlineErrorKind('non-retryable'); + return; + } const classification = classifyPrReviewMutationError(lastError); if (classification.kind === 'bad-request' || classification.kind === 'forbidden') { setInlineError( diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts new file mode 100644 index 0000000000..d94157efdc --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts @@ -0,0 +1,211 @@ +// P1-A-08c wiring tests for `useReplyToCommentMutation`. +// +// Replies are NOT optimistic (per the S7b contract): the comment is +// appended only after the server confirms. These tests assert the HOOK +// WIRING — `mutationFn` delegates to +// `trpcClient.githubPrReview.replyToComment.mutate`, the hoisted operation +// key is merged into the input, and the key rotation policy (real +// `isPrMutationRetryable` + `mapPrOperationError`) runs inside +// `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React +// ref state that needs a mounted renderer, covered by +// `pr-operation-ledger.mounted.test.tsx`). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + replyToCommentIntentFingerprint, + useReplyToCommentMutation, +} from './use-review-discussion-mutations'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; +}; + +let lastCapturedOptions: MutationOptions | null = null; +const replyMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutateAsync: vi.fn(), mutate: vi.fn() }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + }), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + githubPrReview: { + listReviewThreads: { pathFilter: () => ['githubPrReview', 'listReviewThreads'] }, + }, + }), + trpcClient: { + githubPrReview: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + replyToComment: { mutate: (vars: unknown) => replyMutateMock(vars) }, + }, + }, +})); + +vi.mock('sonner-native', () => ({ + toast: { error: (msg: string) => toastErrorMock(msg) }, +})); + +const REPLY_INPUT = { + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 42, + body: 'good point', +}; + +describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + replyMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mounts a useMutation with a custom mutationFn', () => { + useReplyToCommentMutation(); + expect(lastCapturedOptions?.mutationFn).toBeDefined(); + }); + + it('delegates the input to replyToComment.mutate and resolves the reply', async () => { + const reply = { id: 43, htmlUrl: 'https://example.com' }; + replyMutateMock.mockResolvedValueOnce(reply); + useReplyToCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(REPLY_INPUT)).resolves.toEqual(reply); + expect(replyMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 42, + body: 'good point', + }) + ); + }); + + it('merges the hoisted operation key into the reply input (P1-A-08c)', async () => { + replyMutateMock.mockResolvedValueOnce({ id: 43 }); + useReplyToCommentMutation(); + + await lastCapturedOptions?.mutationFn?.(REPLY_INPUT); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(replyMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + }); + + it('regenerates the key after a successful reply (fresh intent next)', async () => { + replyMutateMock.mockResolvedValueOnce({ id: 43 }); + useReplyToCommentMutation(); + + await lastCapturedOptions?.mutationFn?.(REPLY_INPUT); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto the reply retryable copy', async () => { + replyMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useReplyToCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(REPLY_INPUT)).rejects.toMatchObject({ + message: 'Could not reply.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on a retryable network failure (the ledger owns the retry)', async () => { + replyMutateMock.mockRejectedValueOnce(new Error('Network request failed')); + useReplyToCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(REPLY_INPUT)).rejects.toMatchObject({ + message: 'Network request failed', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Comment is too long'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + replyMutateMock.mockRejectedValueOnce(badRequest); + useReplyToCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(REPLY_INPUT)).rejects.toMatchObject({ + message: 'Comment is too long', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('maps the ambiguous ledger marker onto the verify-before-retrying copy in onError', () => { + useReplyToCommentMutation(); + lastCapturedOptions?.onError?.(new Error("Couldn't confirm — check the PR before retrying.")); + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying."); + }); + + it('onError still toasts the message (so the retryable inline error surfaces)', () => { + useReplyToCommentMutation(); + lastCapturedOptions?.onError?.(new Error('boom')); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('onSettled invalidates the listReviewThreads cache', async () => { + useReplyToCommentMutation(); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']); + }); +}); + +describe('replyToCommentIntentFingerprint (P1-A-08c changed-input)', () => { + it('stays stable for a retry of the same reply and rotates when the body or target changes', () => { + const original = replyToCommentIntentFingerprint(REPLY_INPUT); + expect(replyToCommentIntentFingerprint(REPLY_INPUT)).toBe(original); + + const editedBody = replyToCommentIntentFingerprint({ + ...REPLY_INPUT, + body: 'good point, edited', + }); + expect(editedBody).not.toBe(original); + + const otherComment = replyToCommentIntentFingerprint({ + ...REPLY_INPUT, + commentId: 43, + }); + expect(otherComment).not.toBe(original); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts index 832987de2b..bb91dfc29e 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts @@ -36,7 +36,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; -import { useTRPC } from '@/lib/trpc'; +import { trpcClient, useTRPC } from '@/lib/trpc'; +import { + isPrMutationRetryable, + mapPrOperationError, + prOperationToastMessage, + useHoistedOperationKey, +} from '@/lib/pr-review/merge/pr-operation-ledger'; import { applyReactionToggle, @@ -61,21 +67,56 @@ async function invalidateDiscussionCaches( // ── Reply (not optimistic) ──────────────────────────────────────────── +export type ReplyToCommentInput = { + owner: string; + repo: string; + number: number; + commentId: number; + body: string; +}; + +/** + * Deterministic intent fingerprint for a reply submit. The retry of the SAME + * reply text on the SAME comment reuses the hoisted operation key; changing + * the reply body or the target comment rotates the key so a changed intent + * cannot replay the previous reply's canonical ledger result. + */ +export function replyToCommentIntentFingerprint(input: ReplyToCommentInput): string { + return JSON.stringify({ + resource: [input.owner, input.repo, input.number], + commentId: input.commentId, + body: input.body, + }); +} + export function useReplyToCommentMutation() { - const trpc = useTRPC(); const queryClient = useQueryClient(); const keys = useDiscussionKeys(); + const { getKey, rotateKey } = useHoistedOperationKey(); - return useMutation( - trpc.githubPrReview.replyToComment.mutationOptions({ - onError: (error: { message: string }) => { - toast.error(error.message); - }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); - }, - }) - ); + return useMutation({ + mutationFn: async (input: ReplyToCommentInput) => { + try { + const result = await trpcClient.githubPrReview.replyToComment.mutate({ + ...input, + operationKey: getKey(replyToCommentIntentFingerprint(input)), + }); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'reply'); + } + }, + onError: (error: { message: string }) => { + toast.error(prOperationToastMessage(error, 'reply')); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }); } // ── Resolve / unresolve (optimistic) ────────────────────────────────── diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.mounted.test.tsx b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.mounted.test.tsx new file mode 100644 index 0000000000..dc9176d3de --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.mounted.test.tsx @@ -0,0 +1,126 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +// Mounted tests for `useHoistedOperationKey` (P1-A-08c): the hook must +// return one stable key per unchanged user intent across retries, rotate the +// key the moment an intent input changes (the caller passes an intent +// fingerprint), and regenerate the key for the next fresh intent on +// `rotateKey()`. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { expect, test, vi } from 'vitest'; + +import { useHoistedOperationKey } from './pr-operation-ledger'; + +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `uuid-${n}`; + }, + }; +}); + +type LedgerApi = ReturnType; + +function KeyHarness({ onRender }: { onRender: (api: LedgerApi) => void }) { + onRender(useHoistedOperationKey()); + return null; +} + +async function mountHarness(): Promise<{ + renderer: TestRenderer.ReactTestRenderer; + api: LedgerApi; +}> { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + const apiRef: { current: LedgerApi | undefined } = { current: undefined }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create( + createElement(KeyHarness, { + onRender: value => { + apiRef.current = value; + }, + }) + ); + }); + const renderer = rendererRef.current; + const api = apiRef.current; + if (!renderer || !api) { + throw new Error('key harness did not render'); + } + return { renderer, api }; +} + +test('returns a stable key across retries of the same intent fingerprint', async () => { + const { renderer, api } = await mountHarness(); + + const first = api.getKey('fp-comment-v1'); + expect(api.getKey('fp-comment-v1')).toBe(first); + expect(api.getKey('fp-comment-v1')).toBe(first); + + renderer.unmount(); +}); + +test('rotates the key when an intent input changes (changed input after retry)', async () => { + const { renderer, api } = await mountHarness(); + + // First submit: body "nit". Fails retryably; the retry keeps the key. + const original = api.getKey('fp-body-nit'); + expect(api.getKey('fp-body-nit')).toBe(original); + + // The user edits the comment body before retrying → a FRESH intent, so the + // key MUST rotate (the old key must never replay the old intent's result). + const edited = api.getKey('fp-body-nit-v2'); + expect(edited).not.toBe(original); + + // Retry of the edited body keeps the edited key. + expect(api.getKey('fp-body-nit-v2')).toBe(edited); + + renderer.unmount(); +}); + +test('rotates the key on a different review contents fingerprint and keeps it for that fingerprint', async () => { + const { renderer, api } = await mountHarness(); + + const approve = api.getKey('fp-event-APPROVE'); + expect(api.getKey('fp-event-APPROVE')).toBe(approve); + + // Changing the review event to REQUEST_CHANGES is a new intent. + const changes = api.getKey('fp-event-REQUEST_CHANGES'); + expect(changes).not.toBe(approve); + expect(api.getKey('fp-event-REQUEST_CHANGES')).toBe(changes); + + // A different merge message is a new intent too. + const mergeV1 = api.getKey('fp-message-v1'); + const mergeV2 = api.getKey('fp-message-v2'); + expect(mergeV1).not.toBe(approve); + expect(mergeV2).not.toBe(mergeV1); + + renderer.unmount(); +}); + +test('regenerates the key on rotate so the next submit is a fresh intent', async () => { + const { renderer, api } = await mountHarness(); + + const first = api.getKey('fp-comment-v1'); + api.rotateKey(); + const second = api.getKey('fp-comment-v1'); + + expect(second).not.toBe(first); + expect(api.getKey('fp-comment-v1')).toBe(second); + + renderer.unmount(); +}); + +test('a fresh mount (new intent) uses a different key than the previous mount', async () => { + const firstMount = await mountHarness(); + const secondMount = await mountHarness(); + + expect(secondMount.api.getKey('fp-x')).not.toBe(firstMount.api.getKey('fp-x')); + + firstMount.renderer.unmount(); + secondMount.renderer.unmount(); +}); diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts new file mode 100644 index 0000000000..5fc7b1440e --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts @@ -0,0 +1,155 @@ +// Pure tests for the PR operation ledger mobile helpers (P1-A-08c). +// +// `useHoistedOperationKey` is React state (useRef) and is covered by the +// mounted test (`pr-operation-ledger.mounted.test.tsx`). This suite covers +// the error classification, the ledger-outcome → display-copy mapping, and +// the toast message selection without any React. + +import { describe, expect, it, vi } from 'vitest'; + +import { + isPrMutationRetryable, + isPrOperationAmbiguous, + isPrOperationInProgress, + isPrOperationPersistenceFailed, + mapPrOperationError, + PR_OPERATION_AMBIGUOUS_MESSAGE, + PR_OPERATION_IN_PROGRESS_MESSAGE, + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, + prOperationToastMessage, +} from '@/lib/pr-review/merge/pr-operation-ledger'; + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used-in-pure-tests', +})); + +const IN_PROGRESS = new Error(PR_OPERATION_IN_PROGRESS_MESSAGE); +const AMBIGUOUS = new Error(PR_OPERATION_AMBIGUOUS_MESSAGE); +const PERSISTENCE_FAILED = new Error(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + +function trpcError(code: string, message: string): Error { + const error = new Error(message); + Object.assign(error, { data: { code, message } }); + return error; +} + +describe('isPrOperationInProgress / isPrOperationAmbiguous / isPrOperationPersistenceFailed', () => { + it('detects the stable ledger CONFLICT markers by exact message', () => { + expect(isPrOperationInProgress(IN_PROGRESS)).toBe(true); + expect(isPrOperationAmbiguous(AMBIGUOUS)).toBe(true); + expect(isPrOperationInProgress(AMBIGUOUS)).toBe(false); + expect(isPrOperationAmbiguous(IN_PROGRESS)).toBe(false); + expect(isPrOperationInProgress(new Error('other'))).toBe(false); + expect(isPrOperationAmbiguous('string error')).toBe(false); + }); + + it('detects the persistence-failure marker distinctly from the other markers', () => { + expect(isPrOperationPersistenceFailed(PERSISTENCE_FAILED)).toBe(true); + expect(isPrOperationPersistenceFailed(AMBIGUOUS)).toBe(false); + expect(isPrOperationPersistenceFailed(IN_PROGRESS)).toBe(false); + expect(isPrOperationPersistenceFailed(new Error('other'))).toBe(false); + expect(isPrOperationPersistenceFailed('string error')).toBe(false); + }); +}); + +describe('mapPrOperationError', () => { + it.each([ + ['create-comment', 'Could not post comment.'], + ['submit-review', 'Could not submit review. Check your connection and try again.'], + ['reply', 'Could not reply.'], + ['merge', 'Could not merge pull request.'], + ] as const)( + 'maps operation_in_progress onto the existing %s retryable copy', + (surface, expected) => { + const mapped = mapPrOperationError(IN_PROGRESS, surface); + expect(mapped).toBeInstanceOf(Error); + expect((mapped as Error).message).toBe(expected); + } + ); + + it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)( + 'maps the ambiguous outcome onto the verify-before-retrying copy for %s', + surface => { + const mapped = mapPrOperationError(AMBIGUOUS, surface); + expect(mapped).toBeInstanceOf(Error); + expect((mapped as Error).message).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE); + } + ); + + it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)( + 'maps the persistence-failure marker onto the terminal could-not-record copy for %s', + surface => { + const mapped = mapPrOperationError(PERSISTENCE_FAILED, surface); + expect(mapped).toBeInstanceOf(Error); + expect((mapped as Error).message).toBe(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + } + ); + + it('passes every other error through unchanged (same identity)', () => { + const original = trpcError('BAD_REQUEST', 'Cannot approve your own pull request'); + expect(mapPrOperationError(original, 'submit-review')).toBe(original); + const retryable = new Error('Network request failed'); + expect(mapPrOperationError(retryable, 'create-comment')).toBe(retryable); + }); +}); + +describe('prOperationToastMessage', () => { + it('returns the mapped surface copy for an in-progress marker', () => { + expect(prOperationToastMessage(IN_PROGRESS, 'merge')).toBe('Could not merge pull request.'); + }); + + it('returns the ambiguous copy for the ambiguous marker', () => { + expect(prOperationToastMessage(AMBIGUOUS, 'reply')).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE); + }); + + it('returns the terminal could-not-record copy for the persistence-failure marker', () => { + expect(prOperationToastMessage(PERSISTENCE_FAILED, 'merge')).toBe( + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE + ); + }); + + it('returns the underlying message for a passthrough error', () => { + expect(prOperationToastMessage(new Error('boom'), 'merge')).toBe('boom'); + }); + + it('returns a fallback for non-Error values', () => { + expect(prOperationToastMessage('not an error', 'merge')).toBe( + 'Could not complete this action.' + ); + }); +}); + +describe('isPrMutationRetryable (operation-key rotation policy)', () => { + it('keeps the key on retryable ledger outcomes (in-progress, ambiguous)', () => { + expect(isPrMutationRetryable(IN_PROGRESS)).toBe(true); + expect(isPrMutationRetryable(AMBIGUOUS)).toBe(true); + }); + + it('keeps the key on network / 5xx / rate-limit retryable failures', () => { + expect(isPrMutationRetryable(new Error('Network request failed'))).toBe(true); + expect(isPrMutationRetryable(trpcError('INTERNAL_SERVER_ERROR', 'boom'))).toBe(true); + expect(isPrMutationRetryable(trpcError('TIMEOUT', 'timeout'))).toBe(true); + expect(isPrMutationRetryable(trpcError('TOO_MANY_REQUESTS', 'slow down'))).toBe(true); + }); + + it('regenerates the key on non-retryable failures (bad-request, forbidden, reconnect)', () => { + expect( + isPrMutationRetryable(trpcError('BAD_REQUEST', 'Cannot approve your own pull request')) + ).toBe(false); + expect(isPrMutationRetryable(trpcError('FORBIDDEN', 'no permission'))).toBe(false); + expect(isPrMutationRetryable(trpcError('PRECONDITION_FAILED', 'reconnect'))).toBe(false); + expect(isPrMutationRetryable(trpcError('UNAUTHORIZED', 'bad credentials'))).toBe(false); + }); + + it('regenerates the key on the persistence-failure marker even though it is INTERNAL_SERVER_ERROR', () => { + // The server signals reconcile-pending persistence failure with an + // INTERNAL_SERVER_ERROR marker. It must NOT be treated as a generic + // retryable failure: the same key was never marked reconcile-pending, so a + // same-key retry could re-execute a possibly-committed write. + const persistenceError = trpcError( + 'INTERNAL_SERVER_ERROR', + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE + ); + expect(isPrMutationRetryable(persistenceError)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts new file mode 100644 index 0000000000..cc10ec2073 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts @@ -0,0 +1,133 @@ +// Mobile-side helpers for the PR operation ledger (P1-A-08c). +// +// The web router admits a `pr`-domain ledger row for each mutation that +// carries an `operationKey`. This module owns two concerns for the hooks: +// +// 1. `useHoistedOperationKey` — one key per user intent, hoisted at the hook +// mount (or first submit) so retries of the SAME intent reuse the key, +// and regenerated after a success or a terminal (non-retryable) failure +// so the next submit is a fresh intent. The caller passes an intent +// fingerprint (derived from every intent-defining mutation input); when +// the fingerprint changes (the user edited the comment body, review +// contents, reply text, merge method/message, or another intent input), +// the stored key is rotated so a changed intent NEVER rides the old +// key and cannot replay the previous intent's canonical ledger result. +// +// 2. Error mapping — the server signals two ledger outcomes with stable +// CONFLICT messages: `operation_in_progress` (a same-key duplicate is +// already in flight or being reconciled) and the ambiguous copy (the +// effect may have committed; the user must verify the PR before +// retrying). The hooks map these onto the existing per-surface retryable +// copy so the inline error boxes and toasts keep their established +// wording and the retry affordance stays untouched. + +import * as Crypto from 'expo-crypto'; +import { useRef } from 'react'; + +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; + +export const PR_OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +export const PR_OPERATION_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the PR before retrying."; +// The server's distinct persistence failure: the ambiguous outcome could not +// be recorded as `reconcile_pending`, so the ambiguous "check before retrying" +// promise (same-key retries dedupe/reconcile) does NOT hold. The same key must +// never be retried against a row that is still `admitted`, so this marker is +// treated as non-retryable and rotates the key like any terminal failure. +export const PR_OPERATION_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +/** The four PR mutation surfaces; each has its own existing retryable copy. */ +export type PrMutationSurface = 'create-comment' | 'submit-review' | 'reply' | 'merge'; + +// Existing retryable fallback copy per surface (mirrors the sheet/composer +// defaults so an in-progress duplicate reads like a normal retryable failure). +const PR_SURFACE_RETRYABLE_COPY: Record = { + 'create-comment': 'Could not post comment.', + 'submit-review': 'Could not submit review. Check your connection and try again.', + reply: 'Could not reply.', + merge: 'Could not merge pull request.', +}; + +export function isPrOperationInProgress(error: unknown): boolean { + return error instanceof Error && error.message === PR_OPERATION_IN_PROGRESS_MESSAGE; +} + +export function isPrOperationAmbiguous(error: unknown): boolean { + return error instanceof Error && error.message === PR_OPERATION_AMBIGUOUS_MESSAGE; +} + +export function isPrOperationPersistenceFailed(error: unknown): boolean { + return error instanceof Error && error.message === PR_OPERATION_PERSISTENCE_FAILED_MESSAGE; +} + +/** + * True when the failure is retryable, so the operation key must be KEPT for + * the next submit (the ledger dedupes / reconciles the same-key retry instead + * of re-executing the write). Non-retryable failures (bad-request, forbidden, + * reconnect, and the ledger persistence-failure marker) end the intent: the + * next submit is a fresh intent with a fresh key, otherwise a same-key retry + * would replay a settled `failed` row or hit an admitted row that never became + * `reconcile_pending`. + */ +export function isPrMutationRetryable(error: unknown): boolean { + if (isPrOperationPersistenceFailed(error)) { + return false; + } + return classifyPrReviewMutationError(error).kind === 'retryable'; +} + +/** + * Maps the ledger outcome markers onto their display copy. `operation_in_progress` + * becomes the surface's existing retryable message; the ambiguous outcome becomes the + * "verify the PR before retrying" copy (no new CTA); the persistence-failure marker + * becomes the terminal "could not record" copy (no retry CTA — the same key must not + * be retried). All other errors pass through unchanged so the existing classification + * (bad-request / forbidden / reconnect / raw retryable message) keeps its current + * behavior. + */ +export function mapPrOperationError(error: unknown, surface: PrMutationSurface): unknown { + if (isPrOperationInProgress(error)) { + return new Error(PR_SURFACE_RETRYABLE_COPY[surface]); + } + if (isPrOperationAmbiguous(error)) { + return new Error(PR_OPERATION_AMBIGUOUS_MESSAGE); + } + if (isPrOperationPersistenceFailed(error)) { + return new Error(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + } + return error; +} + +/** Toast message for a PR mutation error, after ledger-outcome mapping. */ +export function prOperationToastMessage(error: unknown, surface: PrMutationSurface): string { + const mapped = mapPrOperationError(error, surface); + return mapped instanceof Error ? mapped.message : 'Could not complete this action.'; +} + +/** + * Hoists one operation key per intent. `getKey(fingerprint)` returns a stable + * key across retries of the SAME intent fingerprint and rotates the key the + * moment the fingerprint changes, so a changed intent (edited body, new + * review contents, different merge method/message, …) becomes a fresh intent + * with a fresh key instead of replaying the previous intent's canonical + * result. `rotateKey()` regenerates the key for the next fresh intent (after + * a success or a terminal failure). The key is created lazily so hooks that + * never submit do not burn UUIDs. + */ +export function useHoistedOperationKey(): { + getKey: (fingerprint: string) => string; + rotateKey: () => void; +} { + const keyRef = useRef<{ fingerprint: string; key: string } | null>(null); + const getKey = (fingerprint: string) => { + if (keyRef.current !== null && keyRef.current.fingerprint !== fingerprint) { + keyRef.current = null; + } + keyRef.current ??= { fingerprint, key: Crypto.randomUUID() }; + return keyRef.current.key; + }; + const rotateKey = () => { + keyRef.current = null; + }; + return { getKey, rotateKey }; +} diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts index 2f2a7eacc1..cf669dc142 100644 --- a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.test.ts @@ -6,12 +6,35 @@ // and then routes the result through `assertMergeResult`, so a // `merged: false` reply throws `MergeNotCompletedError` and lands in // React Query's `onError` (NOT `onSuccess`). +// +// P1-A-08c wiring: the hoisted operation key is merged into the mutate +// input and the key rotation policy (real `isPrMutationRetryable`) runs +// inside `mutationFn`; only `useHoistedOperationKey` is mocked (it holds +// React ref state that needs a mounted renderer). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useMergePullRequestMutation } from './use-pr-merge-mutations'; -import { MergeNotCompletedError } from './merge-result-error'; +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { + mergePullRequestIntentFingerprint, + useMergePullRequestMutation, +} from './use-pr-merge-mutations'; +import { MergeNotCompletedError } from './merge-result-error'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); type MutationOptions = { mutationFn?: (vars: unknown) => Promise; @@ -72,6 +95,8 @@ describe('useMergePullRequestMutation (P0-B-08 wiring)', () => { mutateMock.mockReset(); invalidateQueriesMock.mockReset(); toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); }); afterEach(() => { @@ -146,4 +171,103 @@ describe('useMergePullRequestMutation (P0-B-08 wiring)', () => { lastCapturedOptions?.onError?.(new Error('boom')); expect(toastErrorMock).toHaveBeenCalledWith('boom'); }); + + it('merges the hoisted operation key into the merge input (P1-A-08c)', async () => { + mutateMock.mockResolvedValueOnce({ merged: true, sha: 's1', branchDeleted: true }); + useMergePullRequestMutation(REF); + + await lastCapturedOptions?.mutationFn?.(INPUT); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + method: 'squash', + expectedHeadSha: 'a'.repeat(40), + }) + ); + }); + + it('regenerates the key after a successful merge (fresh intent next)', async () => { + mutateMock.mockResolvedValueOnce({ merged: true, sha: 's1', branchDeleted: true }); + useMergePullRequestMutation(REF); + + await lastCapturedOptions?.mutationFn?.(INPUT); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key when GitHub declines the merge (merged:false is retryable)', async () => { + mutateMock.mockResolvedValueOnce({ merged: false, sha: 's1', branchDeleted: false }); + useMergePullRequestMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(INPUT)).rejects.toBeInstanceOf( + MergeNotCompletedError + ); + + // The key stays stable so the next same-intent retry reconciles on the + // server instead of admitting a brand-new operation. + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Cannot approve your own pull request'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + mutateMock.mockRejectedValueOnce(badRequest); + useMergePullRequestMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(INPUT)).rejects.toMatchObject({ + message: 'Cannot approve your own pull request', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto the merge retryable copy', async () => { + mutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useMergePullRequestMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(INPUT)).rejects.toMatchObject({ + message: 'Could not merge pull request.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('maps the ambiguous ledger marker onto the verify-before-retrying copy in onError', () => { + useMergePullRequestMutation(REF); + lastCapturedOptions?.onError?.(new Error("Couldn't confirm — check the PR before retrying.")); + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying."); + }); +}); + +describe('mergePullRequestIntentFingerprint (P1-A-08c changed-input)', () => { + it('stays stable for a retry of the same merge and rotates when the method or message changes', () => { + const original = mergePullRequestIntentFingerprint(INPUT); + expect(mergePullRequestIntentFingerprint(INPUT)).toBe(original); + + const changedMethod = mergePullRequestIntentFingerprint({ ...INPUT, method: 'rebase' }); + expect(changedMethod).not.toBe(original); + + const changedMessage = mergePullRequestIntentFingerprint({ + ...INPUT, + commitMessage: 'merge it now', + }); + expect(changedMessage).not.toBe(original); + + const changedFence = mergePullRequestIntentFingerprint({ + ...INPUT, + expectedHeadSha: 'b'.repeat(40), + }); + expect(changedFence).not.toBe(original); + + const changedDeleteBranch = mergePullRequestIntentFingerprint({ + ...INPUT, + deleteBranch: false, + }); + expect(changedDeleteBranch).not.toBe(original); + }); }); diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts index a0c0fbe571..b257df732f 100644 --- a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts @@ -20,6 +20,12 @@ import { assertMergeResult, type MergePullRequestResult, } from '@/lib/pr-review/merge/merge-result-gate'; +import { + isPrMutationRetryable, + mapPrOperationError, + prOperationToastMessage, + useHoistedOperationKey, +} from '@/lib/pr-review/merge/pr-operation-ledger'; type PrRef = { owner: string; repo: string; number: number }; @@ -34,6 +40,25 @@ type MergePullRequestInput = { expectedHeadSha: string; }; +/** + * Deterministic intent fingerprint for a merge submit. Every intent-defining + * input is included: merge method, commit title/message, delete-branch flag, + * the expected-head fence, and the resource. A retry of the SAME merge reuses + * the hoisted operation key; changing the method or message (or another input) + * rotates the key so a changed intent cannot replay the previous merge's + * canonical ledger result. + */ +export function mergePullRequestIntentFingerprint(input: MergePullRequestInput): string { + return JSON.stringify({ + resource: [input.owner, input.repo, input.number], + method: input.method, + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + deleteBranch: input.deleteBranch, + expectedHeadSha: input.expectedHeadSha, + }); +} + function usePrRefKeys(ref: PrRef) { const trpc = useTRPC(); return { @@ -57,6 +82,7 @@ async function invalidatePrCaches( export function useMergePullRequestMutation(ref: PrRef) { const queryClient = useQueryClient(); const keys = usePrRefKeys(ref); + const { getKey, rotateKey } = useHoistedOperationKey(); // P0-B-08: gate success on the authoritative `merged: true` result // BEFORE React Query resolves the mutation. The server only treats @@ -68,15 +94,32 @@ export function useMergePullRequestMutation(ref: PrRef) { // bad-request), so the submit button stays enabled and the user can // retry. The typed return is preserved so `performSubmit` can read // the sha / branchDeleted / branchDeleteError off the resolved value. + // + // P1-A-08c: the hoisted operation key is merged into the input so a + // same-key retry reconciles against authoritative PR state before + // ever re-merging; it is regenerated after a real merge or a + // non-retryable failure. The two ledger outcome markers are mapped + // onto the existing per-surface copy for the toast. return useMutation({ mutationFn: async input => { - const result = await trpcClient.githubPrReview.mergePullRequest.mutate(input); - // Throws on `merged: false`; returns the gate on clean / partial. - assertMergeResult(result); - return result; + try { + const result = await trpcClient.githubPrReview.mergePullRequest.mutate({ + ...input, + operationKey: getKey(mergePullRequestIntentFingerprint(input)), + }); + // Throws on `merged: false`; returns the gate on clean / partial. + assertMergeResult(result); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'merge'); + } }, onError: (error: { message: string }) => { - toast.error(error.message); + toast.error(prOperationToastMessage(error, 'merge')); }, onSettled: async () => { await invalidatePrCaches(queryClient, keys); diff --git a/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts b/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts index 5c32b9b022..41654145b6 100644 --- a/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts +++ b/apps/mobile/src/lib/pr-review/mutation-error-display.test.ts @@ -1,10 +1,22 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { mutationErrorDisplay, mutationErrorDisplayFromError, } from '@/lib/pr-review/mutation-error-display'; +import { + PR_OPERATION_AMBIGUOUS_MESSAGE, + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, +} from '@/lib/pr-review/merge/pr-operation-ledger'; + +// `mutation-error-display` imports the ambiguous-marker constant from the PR +// operation-ledger helpers, which import `expo-crypto` (and transitively +// react-native). Mock it so this pure suite stays node-only, same as the +// other ledger pure tests. +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used-in-pure-tests', +})); function makeError(code: string, message: string): Error { const error = new Error(message); @@ -27,6 +39,49 @@ describe('mutationErrorDisplay', () => { }); }); + it('shows the verify-before-retry copy inline (no generic retryable message) for an ambiguous submit-review', () => { + // The ledger's ambiguous outcome means the effect may have committed: the + // inline error must pass the marker through verbatim — NOT the generic + // "check your connection" retryable copy — on both surfaces. + const ambiguous = new Error(PR_OPERATION_AMBIGUOUS_MESSAGE); + const classification = classifyPrReviewMutationError(ambiguous); + expect(classification).toEqual({ kind: 'retryable' }); + expect(mutationErrorDisplay('submit', classification, ambiguous)).toEqual({ + kind: 'retryable', + message: PR_OPERATION_AMBIGUOUS_MESSAGE, + }); + expect(mutationErrorDisplay('composer', classification, ambiguous)).toEqual({ + kind: 'retryable', + message: PR_OPERATION_AMBIGUOUS_MESSAGE, + }); + // The convenience wrapper classifies then selects the same inline copy. + expect(mutationErrorDisplayFromError('submit', ambiguous)).toEqual({ + kind: 'retryable', + message: PR_OPERATION_AMBIGUOUS_MESSAGE, + }); + }); + + it('maps the persistence-failure marker to the retry-blocking kind with the honest server copy', () => { + // The reconcile-pending persistence failure must never offer a retry CTA: + // it maps to the retry-blocking bad-request kind with the server's own + // copy (NOT the surface-specific validation copy), on both surfaces. + const persistenceFailed = new Error(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + const classification = classifyPrReviewMutationError(persistenceFailed); + expect(classification).toEqual({ kind: 'retryable' }); + expect(mutationErrorDisplay('composer', classification, persistenceFailed)).toEqual({ + kind: 'bad-request', + message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, + }); + expect(mutationErrorDisplay('submit', classification, persistenceFailed)).toEqual({ + kind: 'bad-request', + message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, + }); + expect(mutationErrorDisplayFromError('submit', persistenceFailed)).toEqual({ + kind: 'bad-request', + message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, + }); + }); + it('uses the surface-specific bad-request inline message', () => { const classification = classifyPrReviewMutationError( makeError('BAD_REQUEST', 'Cannot approve your own pull request') diff --git a/apps/mobile/src/lib/pr-review/mutation-error-display.ts b/apps/mobile/src/lib/pr-review/mutation-error-display.ts index e887721bde..1b6d42cfcd 100644 --- a/apps/mobile/src/lib/pr-review/mutation-error-display.ts +++ b/apps/mobile/src/lib/pr-review/mutation-error-display.ts @@ -4,8 +4,17 @@ // // FORBIDDEN always passes the server-provided classification.message // through verbatim (the server already sanitizes it to actionable copy). +// The PR-operation ambiguous marker ("Couldn't confirm — check the PR before +// retrying.") also passes through verbatim on BOTH surfaces: the effect may +// have committed, so the user must verify the PR instead of being shown the +// generic retryable copy. import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { + isPrOperationPersistenceFailed, + PR_OPERATION_AMBIGUOUS_MESSAGE, + PR_OPERATION_PERSISTENCE_FAILED_MESSAGE, +} from '@/lib/pr-review/merge/pr-operation-ledger'; type MutationErrorDisplaySurface = 'composer' | 'submit'; @@ -35,6 +44,19 @@ export function mutationErrorDisplay( classification: Classification, rawError?: unknown ): MutationErrorDisplay { + // The ledger's ambiguous outcome is NOT the generic retryable copy: the + // effect may have committed, so the user must verify the PR before + // retrying. Pass it through verbatim on both surfaces. + if (rawError instanceof Error && rawError.message === PR_OPERATION_AMBIGUOUS_MESSAGE) { + return { kind: 'retryable', message: PR_OPERATION_AMBIGUOUS_MESSAGE }; + } + // The ledger's persistence-failure marker is retry-BLOCKING: the row never + // became `reconcile_pending`, so the same key must not be retried. Map it to + // the retry-blocking bad-request kind with the honest server copy (not the + // surface-specific validation copy) so no retry CTA is offered. + if (isPrOperationPersistenceFailed(rawError)) { + return { kind: 'bad-request', message: PR_OPERATION_PERSISTENCE_FAILED_MESSAGE }; + } if (classification.kind === 'forbidden') { return { kind: 'forbidden', message: classification.message }; } diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts new file mode 100644 index 0000000000..b6f693dcad --- /dev/null +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.test.ts @@ -0,0 +1,366 @@ +// P1-A-08c wiring tests for `useCreateReviewCommentMutation` and +// `useSubmitReviewMutation`. +// +// The sheet / composer / pending-review surfaces own the inline error +// rendering; these tests assert the HOOK WIRING: each `mutationFn` +// delegates to the matching `trpcClient.githubPrReview..mutate`, +// the hoisted operation key is merged into the input, and the key rotation +// policy (real `isPrMutationRetryable` + `mapPrOperationError`) runs inside +// `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React ref +// state that needs a mounted renderer, covered by +// `pr-operation-ledger.mounted.test.tsx`). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + createReviewCommentIntentFingerprint, + submitReviewIntentFingerprint, + useCreateReviewCommentMutation, + useSubmitReviewMutation, +} from './use-pr-review-mutations'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; +}; + +let lastCapturedOptions: MutationOptions | null = null; +const createCommentMutateMock = vi.fn(); +const submitReviewMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutateAsync: vi.fn(), mutate: vi.fn() }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + }), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + githubPrReview: { + getPullRequest: { queryKey: () => ['githubPrReview', 'getPullRequest'] }, + listReviewThreads: { pathFilter: () => ['githubPrReview', 'listReviewThreads'] }, + }, + }), + trpcClient: { + githubPrReview: { + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + createReviewComment: { mutate: (vars: unknown) => createCommentMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + submitReview: { mutate: (vars: unknown) => submitReviewMutateMock(vars) }, + }, + }, +})); + +vi.mock('sonner-native', () => ({ + toast: { error: (msg: string) => toastErrorMock(msg) }, +})); + +const REF = { owner: 'octocat', repo: 'hello', number: 1 }; + +const COMMENT_INPUT = { + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'inline nit', + path: 'README.md', + line: 3, + side: 'RIGHT' as const, + commitSha: 'a'.repeat(40), +}; + +const REVIEW_INPUT = { + owner: 'octocat', + repo: 'hello', + number: 1, + event: 'APPROVE' as const, + body: 'LGTM', + commitSha: 'a'.repeat(40), + comments: [{ path: 'README.md', line: 3, side: 'RIGHT' as const, body: 'nit' }], +}; + +describe('useCreateReviewCommentMutation (P1-A-08c wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + createCommentMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mounts a useMutation with a custom mutationFn', () => { + useCreateReviewCommentMutation(REF); + expect(lastCapturedOptions?.mutationFn).toBeDefined(); + }); + + it('delegates the input to createReviewComment.mutate and resolves the reply', async () => { + const reply = { id: 42, htmlUrl: 'https://example.com' }; + createCommentMutateMock.mockResolvedValueOnce(reply); + useCreateReviewCommentMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(COMMENT_INPUT)).resolves.toEqual(reply); + expect(createCommentMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'inline nit', + path: 'README.md', + line: 3, + side: 'RIGHT', + commitSha: 'a'.repeat(40), + }) + ); + }); + + it('merges the hoisted operation key into the comment input (P1-A-08c)', async () => { + createCommentMutateMock.mockResolvedValueOnce({ id: 42 }); + useCreateReviewCommentMutation(REF); + + await lastCapturedOptions?.mutationFn?.(COMMENT_INPUT); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(createCommentMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + }); + + it('regenerates the key after a successful post (fresh intent next)', async () => { + createCommentMutateMock.mockResolvedValueOnce({ id: 42 }); + useCreateReviewCommentMutation(REF); + + await lastCapturedOptions?.mutationFn?.(COMMENT_INPUT); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto the comment retryable copy', async () => { + createCommentMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useCreateReviewCommentMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(COMMENT_INPUT)).rejects.toMatchObject({ + message: 'Could not post comment.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on a retryable network failure (the ledger owns the retry)', async () => { + createCommentMutateMock.mockRejectedValueOnce(new Error('Network request failed')); + useCreateReviewCommentMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(COMMENT_INPUT)).rejects.toMatchObject({ + message: 'Network request failed', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Cannot approve your own pull request'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + createCommentMutateMock.mockRejectedValueOnce(badRequest); + useCreateReviewCommentMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(COMMENT_INPUT)).rejects.toMatchObject({ + message: 'Cannot approve your own pull request', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('maps the ambiguous ledger marker onto the verify-before-retrying copy in onError', () => { + useCreateReviewCommentMutation(REF); + lastCapturedOptions?.onError?.(new Error("Couldn't confirm — check the PR before retrying.")); + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying."); + }); + + it('onError still toasts the message (so the retryable inline error surfaces)', () => { + useCreateReviewCommentMutation(REF); + lastCapturedOptions?.onError?.(new Error('boom')); + expect(toastErrorMock).toHaveBeenCalledWith('boom'); + }); + + it('onSettled invalidates the PR review caches (overview + threads)', async () => { + useCreateReviewCommentMutation(REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: ['githubPrReview', 'getPullRequest'], + }); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']); + }); +}); + +describe('useSubmitReviewMutation (P1-A-08c wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + submitReviewMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mounts a useMutation with a custom mutationFn', () => { + useSubmitReviewMutation(REF); + expect(lastCapturedOptions?.mutationFn).toBeDefined(); + }); + + it('delegates the input to submitReview.mutate and resolves the result', async () => { + const result = { id: 7, reviewDecision: 'APPROVED' }; + submitReviewMutateMock.mockResolvedValueOnce(result); + useSubmitReviewMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(REVIEW_INPUT)).resolves.toEqual(result); + expect(submitReviewMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + event: 'APPROVE', + commitSha: 'a'.repeat(40), + }) + ); + }); + + it('merges the hoisted operation key into the review input (P1-A-08c)', async () => { + submitReviewMutateMock.mockResolvedValueOnce({ id: 7 }); + useSubmitReviewMutation(REF); + + await lastCapturedOptions?.mutationFn?.(REVIEW_INPUT); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(submitReviewMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + }); + + it('regenerates the key after a successful submit (fresh intent next)', async () => { + submitReviewMutateMock.mockResolvedValueOnce({ id: 7 }); + useSubmitReviewMutation(REF); + + await lastCapturedOptions?.mutationFn?.(REVIEW_INPUT); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto the submit retryable copy', async () => { + submitReviewMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useSubmitReviewMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(REVIEW_INPUT)).rejects.toMatchObject({ + message: 'Could not submit review. Check your connection and try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Cannot approve your own pull request'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + submitReviewMutateMock.mockRejectedValueOnce(badRequest); + useSubmitReviewMutation(REF); + + await expect(lastCapturedOptions?.mutationFn?.(REVIEW_INPUT)).rejects.toMatchObject({ + message: 'Cannot approve your own pull request', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('maps the ambiguous ledger marker onto the verify-before-retrying copy in onError', () => { + useSubmitReviewMutation(REF); + lastCapturedOptions?.onError?.(new Error("Couldn't confirm — check the PR before retrying.")); + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying."); + }); + + it('onSettled invalidates the PR review caches (overview + threads)', async () => { + useSubmitReviewMutation(REF); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: ['githubPrReview', 'getPullRequest'], + }); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']); + }); +}); + +describe('createReviewCommentIntentFingerprint (P1-A-08c changed-input)', () => { + it('stays stable for a retry of the same comment and rotates when any intent input changes', () => { + const original = createReviewCommentIntentFingerprint(COMMENT_INPUT); + expect(createReviewCommentIntentFingerprint(COMMENT_INPUT)).toBe(original); + + const editedBody = createReviewCommentIntentFingerprint({ + ...COMMENT_INPUT, + body: 'inline nit (edited)', + }); + expect(editedBody).not.toBe(original); + + const movedLine = createReviewCommentIntentFingerprint({ ...COMMENT_INPUT, line: 4 }); + expect(movedLine).not.toBe(original); + + const newCommitSha = createReviewCommentIntentFingerprint({ + ...COMMENT_INPUT, + commitSha: 'b'.repeat(40), + }); + expect(newCommitSha).not.toBe(original); + + const otherRepo = createReviewCommentIntentFingerprint({ + ...COMMENT_INPUT, + repo: 'world', + }); + expect(otherRepo).not.toBe(original); + }); +}); + +describe('submitReviewIntentFingerprint (P1-A-08c changed-input)', () => { + it('stays stable for a retry of the same review and rotates when the event or any comment changes', () => { + const original = submitReviewIntentFingerprint(REVIEW_INPUT); + expect(submitReviewIntentFingerprint(REVIEW_INPUT)).toBe(original); + + const changedEvent = submitReviewIntentFingerprint({ + ...REVIEW_INPUT, + event: 'REQUEST_CHANGES', + }); + expect(changedEvent).not.toBe(original); + + const changedSummary = submitReviewIntentFingerprint({ ...REVIEW_INPUT, body: 'LGTM!!' }); + expect(changedSummary).not.toBe(original); + + const changedComment = submitReviewIntentFingerprint({ + ...REVIEW_INPUT, + comments: [{ path: 'README.md', line: 4, side: 'RIGHT' as const, body: 'nit' }], + }); + expect(changedComment).not.toBe(original); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts index 3df2532958..c4e9cab445 100644 --- a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts @@ -15,11 +15,25 @@ // drains that queue into one `submitReview` call. The submission // uses the LATEST head SHA (per the S3 contract) regardless of what // SHA each item was queued under; a per-item 422 surfaces inline. +// +// P1-A-08c: both hooks hoist one operation key per intent. The key is +// merged into the mutation input, so retries of the same intent dedupe / +// replay / reconcile on the server instead of re-executing the write. The +// key is regenerated after a success or a non-retryable failure (fresh +// intent) and kept across retryable failures (the ledger owns the retry). +// The two ledger outcome markers are mapped onto the existing per-surface +// copy so the inline error boxes and toasts keep their established wording. import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner-native'; -import { useTRPC } from '@/lib/trpc'; +import { trpcClient, useTRPC } from '@/lib/trpc'; +import { + isPrMutationRetryable, + mapPrOperationError, + prOperationToastMessage, + useHoistedOperationKey, +} from '@/lib/pr-review/merge/pr-operation-ledger'; type PrRef = { owner: string; repo: string; number: number }; @@ -41,21 +55,66 @@ async function invalidateReviewCaches( ]); } +export type CreateReviewCommentInput = { + owner: string; + repo: string; + number: number; + body: string; + path: string; + line: number; + side: 'LEFT' | 'RIGHT'; + startLine?: number; + startSide?: 'LEFT' | 'RIGHT'; + commitSha: string; +}; + +/** + * Deterministic intent fingerprint for a create-comment submit. Every + * intent-defining input is included: the retry of the SAME input reuses the + * hoisted operation key, and ANY change (body, path, line, side, commit sha, + * resource) rotates the key so the ledger treats it as a fresh intent. + */ +export function createReviewCommentIntentFingerprint(input: CreateReviewCommentInput): string { + return JSON.stringify({ + resource: [input.owner, input.repo, input.number], + body: input.body, + path: input.path, + line: input.line, + side: input.side, + startLine: input.startLine, + startSide: input.startSide, + commitSha: input.commitSha, + }); +} + export function useCreateReviewCommentMutation(ref: PrRef) { - const trpc = useTRPC(); const queryClient = useQueryClient(); const keys = usePrRefKeys(ref); + const { getKey, rotateKey } = useHoistedOperationKey(); - return useMutation( - trpc.githubPrReview.createReviewComment.mutationOptions({ - onError: (error: { message: string }) => { - toast.error(error.message); - }, - onSettled: async () => { - await invalidateReviewCaches(queryClient, keys); - }, - }) - ); + return useMutation({ + mutationFn: async (input: CreateReviewCommentInput) => { + try { + const result = await trpcClient.githubPrReview.createReviewComment.mutate({ + ...input, + operationKey: getKey(createReviewCommentIntentFingerprint(input)), + }); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'create-comment'); + } + }, + onError: (error: { message: string }) => { + toast.error(prOperationToastMessage(error, 'create-comment')); + }, + onSettled: async () => { + await invalidateReviewCaches(queryClient, keys); + }, + }); } export type SubmitReviewComment = { @@ -77,19 +136,49 @@ export type SubmitReviewInput = { comments?: SubmitReviewComment[]; }; +/** + * Deterministic intent fingerprint for a submit-review. Every intent-defining + * input is included: event, summary body, commit sha, and the full inline + * comment batch (path/line/side/body). A retry of the SAME review reuses the + * hoisted key; changing the event, the summary, or any queued comment rotates + * it so the ledger cannot replay the previous review's canonical result. + */ +export function submitReviewIntentFingerprint(input: SubmitReviewInput): string { + return JSON.stringify({ + resource: [input.owner, input.repo, input.number], + event: input.event, + body: input.body, + commitSha: input.commitSha, + comments: input.comments, + }); +} + export function useSubmitReviewMutation(ref: PrRef) { - const trpc = useTRPC(); const queryClient = useQueryClient(); const keys = usePrRefKeys(ref); + const { getKey, rotateKey } = useHoistedOperationKey(); - return useMutation( - trpc.githubPrReview.submitReview.mutationOptions({ - onError: (error: { message: string }) => { - toast.error(error.message); - }, - onSettled: async () => { - await invalidateReviewCaches(queryClient, keys); - }, - }) - ); + return useMutation({ + mutationFn: async (input: SubmitReviewInput) => { + try { + const result = await trpcClient.githubPrReview.submitReview.mutate({ + ...input, + operationKey: getKey(submitReviewIntentFingerprint(input)), + }); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'submit-review'); + } + }, + onError: (error: { message: string }) => { + toast.error(prOperationToastMessage(error, 'submit-review')); + }, + onSettled: async () => { + await invalidateReviewCaches(queryClient, keys); + }, + }); } diff --git a/apps/web/src/routers/github-pr-review-router.test.ts b/apps/web/src/routers/github-pr-review-router.test.ts index d1798404ed..b4240c287c 100644 --- a/apps/web/src/routers/github-pr-review-router.test.ts +++ b/apps/web/src/routers/github-pr-review-router.test.ts @@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'; import { createCallerFactory } from '@/lib/trpc/init'; import type { User } from '@kilocode/db/schema'; +import { prLedgerResourceKey } from './github-pr-review-router'; const getGitHubUserAccessToken = jest.fn(); @@ -11,6 +12,34 @@ jest.mock('@/lib/integrations/platforms/github/user-token-client', () => ({ getGitHubUserAccessToken: (...args: unknown[]) => getGitHubUserAccessToken(...args), })); +// P1-A-08c: the PR operation ledger. The router admits / settles / +// marks-reconcile-pending through `@kilocode/db/operation-ledger` and +// resolves the analytics identity via `@/lib/drizzle`. Both are mocked so +// the ledger tests can assert admission, settle, replay, and reconcile +// orchestration without a database. The `db` mock resolves an empty user +// list, so `resolvePrDistinctId` falls back to the user id. +const mockAdmitOperation = jest.fn(); +const mockSettleOperation = jest.fn(); +const mockMarkReconcilePending = jest.fn(); + +jest.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: (...args: unknown[]) => mockAdmitOperation(...args), + settleOperation: (...args: unknown[]) => mockSettleOperation(...args), + markReconcilePending: (...args: unknown[]) => mockMarkReconcilePending(...args), +})); + +jest.mock('@/lib/drizzle', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]), + }), + }), + }), + }, +})); + // The retry wrapper invokes `createGitHubPrReviewOctokit(token)` to build the // Octokit handed to the `call` callback. We mock the factory to capture the // token and install per-token call/reject behavior, so we can assert that a @@ -22,6 +51,8 @@ type OctokitMock = { createReview: jest.Mock; createReviewComment: jest.Mock; createReplyForReviewComment: jest.Mock; + getReviewComment: jest.Mock; + getReview: jest.Mock; updateBranch: jest.Mock; listFiles: jest.Mock; get: jest.Mock; @@ -42,6 +73,8 @@ function buildOctokit(token: string): OctokitMock { createReview: jest.fn(), createReviewComment: jest.fn(), createReplyForReviewComment: jest.fn(), + getReviewComment: jest.fn(), + getReview: jest.fn(), updateBranch: jest.fn(), listFiles: jest.fn(), get: jest.fn(), @@ -1094,3 +1127,852 @@ describe('githubPrReviewRouter GraphQL mutations', () => { // Touch the TRPCError import so the linter doesn't strip it (the retry // wrapper surfaces already-classified TRPCError unchanged). void TRPCError; + +// ----- P1-A-08c: PR operation ledger -------------------------------------- +// +// With an `operationKey`, the four PR mutations admit a `pr`-domain ledger +// row, run the GitHub effect only after admission, and dedupe / replay / +// reconcile same-key retries. These tests drive the router through the +// mocked ledger helpers and assert admission payloads, settle outcomes, +// CONFLICT markers, replay markers, reconcile decisions, and the +// `pr_operation_settled` outbox payload (no free text, no resource keys). + +const PR_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the PR before retrying."; +const PR_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const PR_CONFLICT_MESSAGE = 'GitHub reported a conflict for this PR'; +const OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +const PR_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; +const PR_LEDGER_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +function ledgerRow(overrides: Record = {}) { + return { + id: 'ledger-row-1', + status: 'admitted', + canonical_result: null, + intent: 'create_review_comment', + resource_key: commentResourceKey, + ...overrides, + }; +} + +const ledgerCommentInput = { + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'ledger comment body', + path: 'src/foo.ts', + line: 4, + side: 'RIGHT', + commitSha: '0'.repeat(40), + operationKey: 'key-comment-1', +}; + +const ledgerMergeInput = { + ...baseMergeInput, + operationKey: 'key-merge-1', +}; + +// The stored ledger identity embeds the deterministic request fingerprint; +// tests build the exact value so the post-admission row comparison matches. +const commentResourceKey = prLedgerResourceKey('create_review_comment', ledgerCommentInput); +const mergeResourceKey = prLedgerResourceKey('merge', ledgerMergeInput); +const replyResourceKey = prLedgerResourceKey('reply_comment', { + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 5, + body: 'thanks', +}); +const reviewResourceKey = prLedgerResourceKey('submit_review', { + owner: 'octocat', + repo: 'hello', + number: 1, + event: 'APPROVE', + body: 'lgtm!!!', + commitSha: '0'.repeat(40), + comments: [{ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }], +}); + +describe('githubPrReviewRouter PR operation ledger (P1-A-08c)', () => { + beforeEach(() => { + mockAdmitOperation.mockReset(); + mockSettleOperation.mockReset(); + mockMarkReconcilePending.mockReset(); + mockSettleOperation.mockResolvedValue({ settled: true }); + mockMarkReconcilePending.mockResolvedValue(ledgerRow({ status: 'reconcile_pending' })); + }); + + it('admits createReviewComment under domain pr and settles completed at the effect boundary', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockResolvedValueOnce({ + data: { id: 42, node_id: 'N_42' }, + }); + + const result = await caller.createReviewComment(ledgerCommentInput); + + expect(result).toEqual({ commentId: 42, nodeId: 'N_42' }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + userId: 'user-1', + domain: 'pr', + intent: 'create_review_comment', + operationKey: 'key-comment-1', + resourceKey: commentResourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: 120, + }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { commentId: 42, nodeId: 'N_42' }, + }) + ); + // The outbox event is the pr_operation_settled catalog schema with + // enum-only properties: no free text, no resource key. + const settleCall = mockSettleOperation.mock.calls[0][1] as { + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleCall.outboxEvent.eventName).toBe('pr_operation_settled'); + expect(settleCall.outboxEvent.properties).toEqual( + expect.objectContaining({ + source: 'web', + surface: 'pr', + phase: 'terminal', + intent: 'create_review_comment', + outcome: 'completed', + duration_ms: expect.any(Number), + }) + ); + const serialized = JSON.stringify(settleCall.outboxEvent.properties); + expect(serialized).not.toContain('octocat/hello#1'); + expect(serialized).not.toContain('ledger comment body'); + }); + + it('admits and settles replyToComment with a commentId canonical result', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'reply_comment', resource_key: replyResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReplyForReviewComment.mockResolvedValueOnce({ + data: { id: 9, node_id: 'N_9' }, + }); + + const result = await caller.replyToComment({ + owner: 'octocat', + repo: 'hello', + number: 1, + commentId: 5, + body: 'thanks', + operationKey: 'key-reply-1', + }); + + expect(result).toEqual({ commentId: 9, nodeId: 'N_9' }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ intent: 'reply_comment', operationKey: 'key-reply-1' }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + canonicalResult: { commentId: 9, nodeId: 'N_9' }, + }) + ); + }); + + it('admits and settles submitReview and keeps free text out of the canonical result', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'submit_review', resource_key: reviewResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReview.mockResolvedValueOnce({ + data: { id: 99, node_id: 'N_99', state: 'APPROVE' }, + }); + + const result = await caller.submitReview({ + owner: 'octocat', + repo: 'hello', + number: 1, + event: 'APPROVE', + body: 'lgtm!!!', + commitSha: '0'.repeat(40), + comments: [{ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }], + operationKey: 'key-review-1', + }); + + expect(result).toEqual({ reviewId: 99, nodeId: 'N_99', state: 'APPROVE' }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ intent: 'submit_review', operationKey: 'key-review-1' }) + ); + const settleCall = mockSettleOperation.mock.calls[0][1] as { + canonicalResult: Record; + outboxEvent: { properties: Record }; + }; + // Only opaque provider ids enter the canonical result — the body and the + // inline comment text never do. + expect(settleCall.canonicalResult).toEqual({ reviewId: 99, nodeId: 'N_99' }); + const serialized = JSON.stringify({ + canonical: settleCall.canonicalResult, + properties: settleCall.outboxEvent.properties, + }); + expect(serialized).not.toContain('lgtm!!!'); + expect(serialized).not.toContain('fix me'); + expect(serialized).not.toContain('octocat/hello#1'); + }); + + it('returns CONFLICT operation_in_progress for a live same-key duplicate without touching GitHub', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_in_flight', + row: ledgerRow(), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: OPERATION_IN_PROGRESS_MESSAGE, + }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('replays the sanitized canonical result marked replayed on a same-key settled duplicate', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ + status: 'completed', + canonical_result: { commentId: 7, nodeId: 'N_7' }, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + const result = await caller.createReviewComment(ledgerCommentInput); + + expect(result).toEqual({ commentId: 7, nodeId: 'N_7', replayed: true }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('rejects a replayed settled-failed row as a non-retryable fresh-intent signal', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ status: 'failed' }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: PR_REPLAY_FAILED_MESSAGE, + }); + }); + + it('rejects cross-intent operation-key reuse with operation_key_reuse_mismatch (no effect, no replay)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + // The key already exists but belongs to a DIFFERENT intent (a merge row + // stored under the same operation key). The stored identity must never + // replay or reconcile the comment request. + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: OPERATION_KEY_REUSE_MISMATCH_MESSAGE, + }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('rejects operation-key reuse for a different request fingerprint under the same intent', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + // Same key, same intent, but the stored row was written for a DIFFERENT + // request (the client changed an intent input without rotating). The + // server must not replay the previous request's canonical result. + const editedResourceKey = prLedgerResourceKey('create_review_comment', { + ...ledgerCommentInput, + body: 'edited body', + }); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ + status: 'completed', + canonical_result: { commentId: 7, nodeId: 'N_7' }, + resource_key: editedResourceKey, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: OPERATION_KEY_REUSE_MISMATCH_MESSAGE, + }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('settles a deterministic GitHub rejection as failed and rethrows the typed error', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockRejectedValueOnce({ + status: 422, + message: 'Line was not part of the diff', + }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'failed', + outcomeCode: 'bad_request', + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ outcome: 'failed' }), + }), + }) + ); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('marks an ambiguous 5xx outcome reconcile-pending and surfaces the ambiguous CONFLICT', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockRejectedValueOnce({ + status: 503, + message: 'Service unavailable', + }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(mockMarkReconcilePending).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + outboxEvent: expect.objectContaining({ + eventName: 'pr_operation_settled', + properties: expect.objectContaining({ + intent: 'create_review_comment', + outcome: 'ambiguous', + reconcile_result: 'unresolved', + }), + }), + }) + ); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('marks an ambiguous network/timeout failure reconcile-pending (no status on the raw error)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockRejectedValueOnce(new Error('socket hang up')); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(mockMarkReconcilePending).toHaveBeenCalledTimes(1); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('never re-executes an unresolved same-key retry when no provider reference was recorded', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + // Takeover: the admitted lease expired but the write response was never + // persisted, so no provider reference exists to reconcile against. + mockAdmitOperation.mockResolvedValueOnce({ admission: 'takeover', row: ledgerRow() }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + // The unresolved takeover marks the row reconcile-pending (never settles + // it) so the reconciliation lease is immediately claimable and the + // ambiguous outbox event is recorded exactly once. + expect(mockMarkReconcilePending).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + outboxEvent: expect.objectContaining({ + eventName: 'pr_operation_settled', + properties: expect.objectContaining({ + intent: 'create_review_comment', + outcome: 'ambiguous', + reconcile_result: 'unresolved', + }), + }), + }) + ); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('reconciles a same-key retry by re-reading the recorded provider reference and replays it', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + canonical_result: { commentId: 42, nodeId: 'N_42' }, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.getReviewComment.mockResolvedValueOnce({ + data: { id: 42, node_id: 'N_42' }, + }); + + const result = await caller.createReviewComment(ledgerCommentInput); + + expect(result).toEqual({ commentId: 42, nodeId: 'N_42', replayed: true }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { commentId: 42, nodeId: 'N_42' }, + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ + outcome: 'completed', + reconcile_result: 'confirmed_completed', + }), + }), + }) + ); + }); + + it('settles failed as confirmed_absent when the recorded provider reference is gone', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + canonical_result: { commentId: 42, nodeId: 'N_42' }, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.getReviewComment.mockRejectedValueOnce({ status: 404, message: 'gone' }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'failed', + outcomeCode: 'effect_absent', + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ + outcome: 'failed', + reconcile_result: 'confirmed_absent', + }), + }), + }) + ); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + }); + + it('reconciles a same-key merge retry to completed when the PR is merged', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + intent: 'merge', + resource_key: mergeResourceKey, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.get.mockResolvedValueOnce({ + data: { + state: 'closed', + merged: true, + merge_commit_sha: 'm1', + head: { ref: 'feature/x', sha: 'a'.repeat(40) }, + }, + }); + + const result = await caller.mergePullRequest(ledgerMergeInput); + + expect(result).toEqual({ merged: true, sha: 'm1', branchDeleted: false, replayed: true }); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + canonicalResult: { merged: true, sha: 'm1', branchDeleted: false }, + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ + intent: 'merge', + outcome: 'completed', + reconcile_result: 'confirmed_completed', + }), + }), + }) + ); + }); + + it('reconciles a same-key merge retry to failed/absent when the PR closed without merging', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + intent: 'merge', + resource_key: mergeResourceKey, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.get.mockResolvedValueOnce({ + data: { state: 'closed', merged: false }, + }); + + await expect(caller.mergePullRequest(ledgerMergeInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_CONFLICT_MESSAGE, + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'failed', outcomeCode: 'already_closed' }) + ); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + }); + + it('reconciles a same-key merge retry to failed/absent when the expected head moved', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + intent: 'merge', + resource_key: mergeResourceKey, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.get.mockResolvedValueOnce({ + data: { state: 'open', head: { ref: 'feature/x', sha: 'b'.repeat(40) } }, + }); + + await expect(caller.mergePullRequest(ledgerMergeInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_CONFLICT_MESSAGE, + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'failed', outcomeCode: 'head_moved' }) + ); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + }); + + it('re-executes the merge when the expected head lineage is intact (takeover)', async () => { + // Two token fetches: one for the reconcile read, one for the re-executed merge. + getGitHubUserAccessToken + .mockResolvedValueOnce(connected('t1', 'auth_1', 1)) + .mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'takeover', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + const prFixture = { + state: 'open', + head: { ref: 'feature/x', sha: 'a'.repeat(40), repo: { id: SAME_REPO_ID } }, + base: { ref: 'main', repo: { id: SAME_REPO_ID } }, + }; + // One read for the reconcile, one read inside runMergeWrite. + t1Octokit.pulls.get.mockResolvedValueOnce({ data: prFixture }).mockResolvedValueOnce({ + data: prFixture, + }); + t1Octokit.pulls.merge.mockResolvedValueOnce({ data: { merged: true, sha: 'm1' } }); + t1Octokit.git.deleteRef.mockResolvedValueOnce({ data: {} }); + + const result = await caller.mergePullRequest(ledgerMergeInput); + + expect(result).toEqual({ merged: true, sha: 'm1', branchDeleted: true }); + expect(t1Octokit.pulls.merge).toHaveBeenCalledTimes(1); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + canonicalResult: { merged: true, sha: 'm1', branchDeleted: true }, + }) + ); + }); + + it('marks a merge takeover reconcile-pending when the authoritative read fails (unresolved)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'takeover', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + // The authoritative PR read fails (network / 5xx): the merge may or may + // not have committed, so the row must stay reconcile-pending and the + // merge must never re-execute under this key. + t1Octokit.pulls.get.mockRejectedValueOnce({ status: 503, message: 'boom' }); + + await expect(caller.mergePullRequest(ledgerMergeInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(mockMarkReconcilePending).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + outboxEvent: expect.objectContaining({ + eventName: 'pr_operation_settled', + properties: expect.objectContaining({ + intent: 'merge', + outcome: 'ambiguous', + reconcile_result: 'unresolved', + }), + }), + }) + ); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + }); + + it('keeps an admitted merge reconcile-pending when the authoritative read returns NOT_FOUND', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + // The merge begins with an authoritative PR read; a NOT_FOUND there means + // the PR state is READ-unavailable, which is ambiguous (the merge may or + // may not have committed) — the row must never settle absent from a + // failed read. + t1Octokit.pulls.get.mockRejectedValueOnce({ status: 404, message: 'not found' }); + + await expect(caller.mergePullRequest(ledgerMergeInput)).rejects.toMatchObject({ + code: 'CONFLICT', + message: PR_AMBIGUOUS_MESSAGE, + }); + expect(mockMarkReconcilePending).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + outboxEvent: expect.objectContaining({ + eventName: 'pr_operation_settled', + properties: expect.objectContaining({ + intent: 'merge', + outcome: 'ambiguous', + reconcile_result: 'unresolved', + }), + }), + }) + ); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + }); + + it('keeps a declined merge un-settled so a later same-key retry reconciles instead of re-merging', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.get.mockResolvedValueOnce({ + data: { + state: 'open', + head: { ref: 'feature/x', sha: 'a'.repeat(40), repo: { id: SAME_REPO_ID } }, + base: { ref: 'main', repo: { id: SAME_REPO_ID } }, + }, + }); + t1Octokit.pulls.merge.mockResolvedValueOnce({ data: { merged: false, sha: 's1' } }); + + const result = await caller.mergePullRequest(ledgerMergeInput); + + expect(result).toEqual({ merged: false, sha: 's1', branchDeleted: false }); + // No settle: the row stays admitted so the next same-key retry can + // reconcile the authoritative PR state before re-merging. + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('surfaces a retryable server error when the completed settle fails after a committed provider write', async () => { + // P1-A-08c + final PR finding: the GitHub effect committed but the ledger + // settle failed. The router must NOT return the success receipt (the row + // is still `admitted`, so a success would falsely claim a retry-safe + // replay); it surfaces a retryable INTERNAL_SERVER_ERROR instead so a + // same-key retry reconciles and settles the row. + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + mockSettleOperation.mockRejectedValueOnce(new Error('db unavailable')); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockResolvedValueOnce({ + data: { id: 42, node_id: 'N_42' }, + }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_SETTLE_FAILED_MESSAGE, + }); + // The completed settle was attempted with the committed provider outcome… + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + canonicalResult: { commentId: 42, nodeId: 'N_42' }, + }) + ); + // …and no ambiguous/conflict marker leaked from the failure path. + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('surfaces the distinct non-retryable persistence error when reconcile-pending persistence fails on an ambiguous outcome', async () => { + // The provider write went ambiguous (5xx) but `markReconcilePending` + // failed: the row was never marked `reconcile_pending`, so the ambiguous + // "check the PR before retrying" CONFLICT (which promises same-key + // dedupe/reconcile) must NOT be surfaced. A distinct persistence error is + // returned instead so the client cannot blind-retry the same key. + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'admitted', row: ledgerRow() }); + mockMarkReconcilePending.mockRejectedValueOnce(new Error('db unavailable')); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReviewComment.mockRejectedValueOnce({ + status: 503, + message: 'Service unavailable', + }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_PERSISTENCE_FAILED_MESSAGE, + }); + expect(mockMarkReconcilePending).toHaveBeenCalledTimes(1); + // The ambiguous CONFLICT must never replace the hidden persistence failure, + // and the row must never be settled from a failed mark. + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('surfaces a retryable server error when a confirmed reconcile cannot settle (never replays an un-recorded row)', async () => { + // The reconcile re-read confirmed the provider reference exists, but the + // completed settle failed. A `replayed: true` success here would be a + // false retry-safe receipt for a row that is still reconcile_pending, so + // the retryable server error is surfaced instead. + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + canonical_result: { commentId: 42, nodeId: 'N_42' }, + }), + }); + mockSettleOperation.mockRejectedValueOnce(new Error('db unavailable')); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.getReviewComment.mockResolvedValueOnce({ + data: { id: 42, node_id: 'N_42' }, + }); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_SETTLE_FAILED_MESSAGE, + }); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 'completed', + canonicalResult: { commentId: 42, nodeId: 'N_42' }, + outboxEvent: expect.objectContaining({ + properties: expect.objectContaining({ reconcile_result: 'confirmed_completed' }), + }), + }) + ); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + }); + + it('surfaces the distinct persistence error when an unresolved takeover cannot mark reconcile-pending', async () => { + // Takeover with no recorded provider reference: presence cannot be + // confirmed, so the row must become `reconcile_pending` before the + // ambiguous outcome is surfaced. When that persistence fails, the distinct + // non-retryable persistence error is surfaced instead — never the ambiguous + // marker and never a re-executed write. + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ admission: 'takeover', row: ledgerRow() }); + mockMarkReconcilePending.mockRejectedValueOnce(new Error('db unavailable')); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.createReviewComment(ledgerCommentInput)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_PERSISTENCE_FAILED_MESSAGE, + }); + expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('surfaces the distinct persistence error when the merge reconcile read is unresolved and the mark fails', async () => { + // The merge reconcile's authoritative read failed (unresolved), so the row + // must stay reconcile-pending before the ambiguous outcome is surfaced. + // When that persistence fails, the distinct non-retryable persistence + // error is surfaced instead of the ambiguous marker. + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'takeover', + row: ledgerRow({ intent: 'merge', resource_key: mergeResourceKey }), + }); + mockMarkReconcilePending.mockRejectedValueOnce(new Error('db unavailable')); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.get.mockRejectedValueOnce({ status: 503, message: 'boom' }); + + await expect(caller.mergePullRequest(ledgerMergeInput)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_PERSISTENCE_FAILED_MESSAGE, + }); + expect(t1Octokit.pulls.merge).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index a08fadacf9..55f7a3d4bd 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -1,9 +1,19 @@ import 'server-only'; import * as z from 'zod'; +import { createHash } from 'node:crypto'; import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { kilocode_users, type OperationLedgerRow } from '@kilocode/db/schema'; +import { + admitOperation, + markReconcilePending, + settleOperation, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; import type { createGitHubPrReviewOctokit } from '@/lib/github-pr-review/client'; import { buildChecksResult, @@ -52,6 +62,12 @@ const ownerRepoSchema = z }) .strict(); +// Client-generated UUID, stable across retries of one user intent. When +// present, the procedure admits the operation into the shared ledger and +// becomes retry-safe (P1-A-08c); when absent, the legacy non-ledger path +// runs unchanged (older mobile clients keep working). +const operationKeySchema = z.string().min(1).max(128).optional(); + const prNumberSchema = z.number().int().positive(); const GetPullRequestInput = ownerRepoSchema.extend({ number: prNumberSchema }).strict(); @@ -102,6 +118,7 @@ const CreateReviewCommentInput = ownerRepoSchema startLine: z.number().int().positive().optional(), startSide: ReviewSideSchema.optional(), commitSha: z.string().min(40).max(64), + operationKey: operationKeySchema, }) .strict() .refine(v => v.startLine === undefined || v.startLine <= v.line, { @@ -118,6 +135,7 @@ const ReplyToCommentInput = ownerRepoSchema number: prNumberSchema, commentId: z.number().int().positive(), body: z.string().min(1).max(65_535), + operationKey: operationKeySchema, }) .strict(); @@ -135,6 +153,7 @@ const SubmitReviewInput = ownerRepoSchema ) .max(100) .optional(), + operationKey: operationKeySchema, }) .strict(); @@ -163,6 +182,7 @@ const MergePullRequestInput = ownerRepoSchema commitMessage: z.string().min(1).max(65_535).optional(), deleteBranch: z.boolean(), expectedHeadSha: z.string().min(40).max(64), + operationKey: operationKeySchema, // Legacy fields — accepted for backward compat, ignored by the server. headRef: z.string().min(1).max(255).optional(), isCrossRepo: z.boolean().optional(), @@ -655,6 +675,785 @@ function requireGraphQlOperation(value: T | null | undefined, operation: stri return value; } +// ----- PR operation ledger (P1-A-08c) ----------------------------------------- + +// Shared per-intent ledger for the four PR write procedures. When the caller +// supplies an `operationKey`, the procedure admits a `pr`-domain row and only +// then runs the GitHub effect; every later same-key call dedupes, replays the +// canonical result, or reconciles before deciding. Deterministic GitHub +// rejections settle the row `failed`; ambiguous network/timeout/5xx outcomes +// become `reconcile_pending` and never re-execute the write under the same +// key. The ledger helpers (`@kilocode/db/operation-ledger`) own admission, +// lease serialization, and the atomic `pr_operation_settled` outbox write. +const PR_LEDGER_DOMAIN = 'pr' as const; +// The in-flight window: while an `admitted` row holds a live lease, same-key +// retries receive CONFLICT `operation_in_progress` instead of re-executing. +const PR_LEDGER_LEASE_SECONDS = 120; + +const PR_LEDGER_INTENTS = [ + 'merge', + 'submit_review', + 'create_review_comment', + 'reply_comment', +] as const; +type PrLedgerIntent = (typeof PR_LEDGER_INTENTS)[number]; + +// Client-facing CONFLICT markers. `operation_in_progress` keeps the existing +// pending/retry UI on mobile; the ambiguous copy tells the user to verify the +// PR before retrying (a retry under the same key never re-executes the write). +// `operation_key_reuse_mismatch` is the cross-intent rejection: a caller that +// reuses an existing key for a DIFFERENT intent/resource/request is refused +// without any effect or replay. +const OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const PR_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the PR before retrying."; +const PR_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const PR_CONFLICT_MESSAGE = 'GitHub reported a conflict for this PR'; +const PR_OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +// A provider-confirmed outcome whose ledger settle failed: the GitHub effect +// DID commit, but the row was not settled. The caller must NOT receive a +// success receipt (the row is still non-terminal, so success would falsely +// claim a retry-safe replay). Surface a retryable server error instead: a +// same-key retry reconciles the committed provider outcome and settles the +// row. +const PR_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; +// An ambiguous outcome whose `reconcile_pending` persistence failed: the row +// was NOT marked reconcile-pending, so the ambiguous marker (which promises +// that same-key retries dedupe and reconcile instead of re-executing) must +// never be surfaced. Return a distinct non-retryable persistence error so the +// client cannot blind-retry the same key against an admitted row. +const PR_LEDGER_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +function operationInProgressError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: OPERATION_IN_PROGRESS_MESSAGE }); +} + +function ambiguousPrError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: PR_AMBIGUOUS_MESSAGE }); +} + +function conflictPrError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: PR_CONFLICT_MESSAGE }); +} + +function operationKeyReuseMismatchError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: PR_OPERATION_KEY_REUSE_MISMATCH_MESSAGE }); +} + +/** + * Deterministic fingerprint of the intent-defining inputs for one PR + * mutation. The same user intent (retry) always produces the same fingerprint; + * ANY change to an intent input (comment body, review contents, reply text, + * merge method/message, resource, fence sha) produces a different one. Folded + * into the ledger `resource_key` so the admission row comparison can reject + * cross-intent operation-key reuse (finding: an existing key with a different + * PR intent/resource/request must never replay the old canonical result). + */ +function prRequestFingerprint(intent: PrLedgerIntent, input: Record): string { + const resource = [input.owner, input.repo, input.number]; + const parts = + intent === 'create_review_comment' + ? { + resource, + body: input.body, + path: input.path, + line: input.line, + side: input.side, + startLine: input.startLine, + startSide: input.startSide, + commitSha: input.commitSha, + } + : intent === 'reply_comment' + ? { + resource, + commentId: input.commentId, + body: input.body, + } + : intent === 'submit_review' + ? { + resource, + event: input.event, + body: input.body, + commitSha: input.commitSha, + comments: input.comments, + } + : { + resource, + method: input.method, + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + deleteBranch: input.deleteBranch, + expectedHeadSha: input.expectedHeadSha, + }; + return createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16); +} + +/** + * The PR ledger resource identity: the domain resource (`owner/repo#number`) + * plus the intent fingerprint. Stored verbatim in `resource_key` (never + * analytics) and compared on every admission so an existing key with a + * different intent/resource/request is rejected instead of replayed. + * Exported so router tests can build the exact stored identity. + */ +export function prLedgerResourceKey( + intent: PrLedgerIntent, + input: Record +): string { + return `${String(input.owner)}/${String(input.repo)}#${String(input.number)}::${prRequestFingerprint(intent, input)}`; +} + +/** + * Best-effort ledger write, reserved for FAILED-status settles only: the + * caller is already receiving a typed rejection (a deterministic GitHub + * rejection or a confirmed-absent reconcile), so a ledger write that fails + * here must never mask the provider outcome — the error is being surfaced + * regardless and a later same-key retry re-records it. Completed settles and + * reconcile-pending marks must NOT use this helper: they gate whether the + * caller is told success or sees the ambiguous marker, so their failures use + * the durable helpers below instead. + */ +async function bestEffortLedgerWrite(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + console.error( + `Failed to write PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Durably settles a provider-confirmed outcome as `completed` (P1-A-08c). The + * GitHub effect committed, so a settle that fails must never be swallowed: + * the row would stay `admitted` while the caller receives a success receipt — + * a false "retry-safe" claim. Throws a retryable server error instead; a + * same-key retry reconciles the committed provider outcome and settles the + * row, and the caller never sees a success for an un-recorded row. + */ +async function settleCompletedPrRow(args: { + rowId: string; + canonicalResult: Record; + outboxEvent: OutboxEventInput; +}): Promise { + try { + await settleOperation(db, { + rowId: args.rowId, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: args.canonicalResult, + outboxEvent: args.outboxEvent, + }); + } catch (error) { + console.error( + `Failed to settle completed PR operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_SETTLE_FAILED_MESSAGE, + cause: error, + }); + } +} + +/** + * Durably marks a PR ledger row `reconcile_pending` with the deterministic + * ambiguous outbox event. The ambiguous CONFLICT is surfaced ONLY after this + * succeeds: if the persistence fails, the row stays `admitted` and a same-key + * retry could re-execute a possibly-committed write, so a distinct + * non-retryable persistence error is thrown instead of the ambiguous marker. + */ +async function markPrRowReconcilePendingDurably(args: { + rowId: string; + outboxEvent: OutboxEventInput; +}): Promise { + try { + await markReconcilePending(db, { + rowId: args.rowId, + outboxEvent: args.outboxEvent, + }); + } catch (error) { + console.error( + `Failed to mark PR operation ledger row reconcile-pending: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: PR_LEDGER_PERSISTENCE_FAILED_MESSAGE, + cause: error, + }); + } +} + +/** + * Marks a PR ledger row `reconcile_pending` with the deterministic ambiguous + * outbox event. Every unresolved reconciliation path MUST call this before + * surfacing the ambiguous CONFLICT: the transition makes the reconciliation + * lease immediately claimable and records the ambiguous outcome exactly once + * (the outbox `event_uuid` is a deterministic UUIDv5 per row+event, so a row + * that is already `reconcile_pending` — the event was emitted when the write + * went ambiguous — is a no-op and never double-emits). A persistence failure + * propagates as the distinct non-retryable persistence error so the ambiguous + * marker is never surfaced without the reconcile-pending guarantee. + */ +async function markPrRowReconcilePending(args: { + row: OperationLedgerRow; + intent: PrLedgerIntent; + distinctId: string; + startedAt: number; +}): Promise { + await markPrRowReconcilePendingDurably({ + rowId: args.row.id, + outboxEvent: prSettledOutboxEvent({ + distinctId: args.distinctId, + intent: args.intent, + outcome: 'ambiguous', + reconcileResult: 'unresolved', + startedAt: args.startedAt, + }), + }); +} + +/** Resolves the analytics identity channel (user email); falls back to the user id. */ +async function resolvePrDistinctId(userId: string): Promise { + try { + const [user] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)) + .limit(1); + return user?.email ?? userId; + } catch (error) { + console.error( + `Failed to resolve user email for PR outbox event: ${error instanceof Error ? error.message : String(error)}` + ); + return userId; + } +} + +/** `pr_operation_settled` outbox payload (DEC-05): no free text, no resource keys. */ +function prSettledOutboxEvent(params: { + distinctId: string; + intent: PrLedgerIntent; + outcome: 'completed' | 'failed' | 'ambiguous'; + reconcileResult?: 'confirmed_completed' | 'confirmed_absent' | 'unresolved'; + startedAt: number; +}): OutboxEventInput { + return { + eventName: 'pr_operation_settled', + distinctId: params.distinctId, + properties: { + source: 'web', + surface: 'pr', + phase: 'terminal', + intent: params.intent, + outcome: params.outcome, + ...(params.reconcileResult !== undefined ? { reconcile_result: params.reconcileResult } : {}), + duration_ms: Math.max(0, Date.now() - params.startedAt), + }, + }; +} + +type PrLedgerMutationArgs = { + userId: string; + intent: PrLedgerIntent; + operationKey: string; + /** Domain resource identity (`owner/repo#number`). Never enters analytics. */ + resourceKey: string; + /** Epoch ms when the user intent started, used for the outbox duration. */ + startedAt: number; + /** Runs the GitHub effect under an already-admitted row. */ + execute: (row: OperationLedgerRow) => Promise; + /** Reconcilies a same-key retry before any effect. */ + reconcile: (row: OperationLedgerRow) => Promise>; +}; + +/** The canonical result replayed under the same key carries `replayed: true`. */ +type ReplayedResult = T & { replayed: true }; + +/** + * Ledger orchestration for a PR mutation (P1-A-08c): + * - `admitted`: run the effect and settle completed / failed / reconcile-pending. + * - `duplicate_settled`: replay the sanitized canonical result marked replayed. + * - `duplicate_in_flight` / `duplicate_reconcile_in_progress`: CONFLICT + * `operation_in_progress` (never re-execute). + * - `takeover` / `duplicate_reconcile_pending`: reconcile before any effect. + * + * Cross-intent key reuse (P1-A-08d): the ledger identity is + * `(user, domain, operation_key)` and `admitOperation` returns the row for + * that key regardless of intent. Before ANY outcome is honored — replay, + * reconcile, in-flight CONFLICT, or a fresh execute — the returned row is + * compared against the request's intent and resource identity (which embeds + * the request fingerprint). A mismatch means the same key is being reused for + * a DIFFERENT PR intent/resource/request: the call is rejected with CONFLICT + * `operation_key_reuse_mismatch` with no effect and no replay of the old + * canonical result. Exact retries (same key, intent, and request fingerprint) + * always match the stored row and keep their existing dedupe/replay/reconcile + * behavior. + */ +async function runPrLedgerMutation( + args: PrLedgerMutationArgs +): Promise> { + const admission = await admitOperation(db, { + userId: args.userId, + domain: PR_LEDGER_DOMAIN, + intent: args.intent, + operationKey: args.operationKey, + resourceKey: args.resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: PR_LEDGER_LEASE_SECONDS, + }); + + if (admission.row.intent !== args.intent || admission.row.resource_key !== args.resourceKey) { + throw operationKeyReuseMismatchError(); + } + + switch (admission.admission) { + case 'admitted': + return args.execute(admission.row); + case 'duplicate_settled': + return replaySettledPrRow(admission.row); + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw operationInProgressError(); + case 'takeover': + case 'duplicate_reconcile_pending': + return args.reconcile(admission.row); + } +} + +/** Replays a terminal row: only `completed`/`no_op` may replay a canonical result. */ +function replaySettledPrRow(row: OperationLedgerRow): ReplayedResult { + if (row.status === 'completed' || row.status === 'no_op') { + return { ...(row.canonical_result ?? {}), replayed: true } as ReplayedResult; + } + // A settled `failed` row cannot be recovered under the same key: surface a + // non-retryable typed rejection so the client starts a fresh intent. + throw new TRPCError({ code: 'BAD_REQUEST', message: PR_REPLAY_FAILED_MESSAGE }); +} + +/** What the GitHub write reported back, and whether it must settle the row. */ +type PrWriteOutcome = + | { kind: 'settle'; canonical: Record; response: T } + | { kind: 'no_settle'; response: T }; + +/** Coarse ledger outcome code derived from the classified tRPC error. */ +function outcomeCodeFromTrpcError(error: unknown): string { + if (error instanceof TRPCError) { + switch (error.code) { + case 'NOT_FOUND': + return 'not_found'; + case 'PRECONDITION_FAILED': + return 'precondition_failed'; + case 'TOO_MANY_REQUESTS': + return 'too_many_requests'; + case 'FORBIDDEN': + return 'forbidden'; + case 'CONFLICT': + return 'conflict'; + default: + return 'bad_request'; + } + } + return 'unclassified'; +} + +/** + * Runs the GitHub write under `withGitHubUserTokenRetry` and settles the + * admitted row. Success settles `completed` at the committed-effect boundary; + * a settle that fails after the commit surfaces a retryable server error + * (never a success receipt for an un-recorded row). A deterministic GitHub + * rejection settles `failed` and rethrows the classified error. An ambiguous + * network/timeout/5xx failure (classified to BAD_GATEWAY before the existing + * conversion) becomes `reconcile_pending` and surfaces the ambiguous outcome — + * the write may have committed, so the row must never settle terminal under + * this key; if the reconcile-pending persistence fails, a distinct + * non-retryable persistence error is surfaced instead of the ambiguous marker. + * A merge NOT_FOUND (the merge begins with an authoritative PR read) is + * read-unavailable and therefore ambiguous too: it is never treated as a + * confirmed rejection. + */ +async function executePrWriteWithLedger(args: { + userId: string; + row: OperationLedgerRow; + intent: PrLedgerIntent; + startedAt: number; + runWrite: (octokit: ReturnType) => Promise>; +}): Promise { + const distinctId = await resolvePrDistinctId(args.userId); + try { + const outcome = await withGitHubUserTokenRetry({ + kiloUserId: args.userId, + call: args.runWrite, + }); + if (outcome.kind === 'no_settle') { + return outcome.response; + } + await settleCompletedPrRow({ + rowId: args.row.id, + canonicalResult: outcome.canonical, + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: args.intent, + outcome: 'completed', + startedAt: args.startedAt, + }), + }); + return outcome.response; + } catch (error) { + if (error instanceof TRPCError && error.code === 'BAD_GATEWAY') { + await markPrRowReconcilePending({ + row: args.row, + intent: args.intent, + distinctId, + startedAt: args.startedAt, + }); + throw ambiguousPrError(); + } + // A merge begins with an authoritative PR read (`pulls.get`). A NOT_FOUND + // there means the PR state is READ-unavailable, which is ambiguous — the + // merge may or may not have committed — and must never settle the row + // absent/terminal. Treat merge NOT_FOUND exactly like a failed read: + // reconcile-pending + ambiguous, so a same-key retry keeps reconciling. + // Comment/review writes keep NOT_FOUND as a deterministic rejection. + if (args.intent === 'merge' && error instanceof TRPCError && error.code === 'NOT_FOUND') { + await markPrRowReconcilePending({ + row: args.row, + intent: args.intent, + distinctId, + startedAt: args.startedAt, + }); + throw ambiguousPrError(); + } + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: outcomeCodeFromTrpcError(error), + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: args.intent, + outcome: 'failed', + startedAt: args.startedAt, + }), + }) + ); + throw error; + } +} + +/** + * Reconcilies a comment/review row on a same-key retry. The write is never + * re-executed under this key (a non-idempotent comment write could duplicate): + * - a recorded provider reference is re-fetched; found → settle completed and + * replay; absent → settle failed and surface the ambiguous outcome; + * - no provider reference was ever recorded (the write response was lost) → + * presence cannot be confirmed → stay reconcile-pending, no GitHub write. + */ +async function reconcileCommentPrRow(args: { + userId: string; + row: OperationLedgerRow; + intent: PrLedgerIntent; + startedAt: number; + providerRefOf: (canonical: Record) => number | null; + readRef: ( + octokit: ReturnType, + providerId: number + ) => Promise<{ canonical: Record; response: T }>; +}): Promise> { + const providerId = args.providerRefOf(args.row.canonical_result ?? {}); + const distinctId = await resolvePrDistinctId(args.userId); + if (providerId === null) { + // The write response was lost before a provider reference was recorded + // (a takeover row or a reconcile-pending row without canonical data). + // Presence cannot be confirmed and the write must NOT re-execute under + // this key: mark reconcile-pending (emitting the deterministic ambiguous + // outbox event) and surface the ambiguous outcome. + await markPrRowReconcilePending({ + row: args.row, + intent: args.intent, + distinctId, + startedAt: args.startedAt, + }); + throw ambiguousPrError(); + } + + let read: + | { + confirmed: 'found'; + canonical: Record; + response: T; + } + | { confirmed: 'absent' } + | { confirmed: 'unresolved' }; + try { + const { canonical, response } = await withGitHubUserTokenRetry({ + kiloUserId: args.userId, + call: octokit => args.readRef(octokit, providerId), + }); + read = { confirmed: 'found', canonical, response }; + } catch (error) { + read = + error instanceof TRPCError && error.code === 'NOT_FOUND' + ? { confirmed: 'absent' } + : { confirmed: 'unresolved' }; + } + + if (read.confirmed === 'found') { + await settleCompletedPrRow({ + rowId: args.row.id, + canonicalResult: read.canonical, + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: args.intent, + outcome: 'completed', + reconcileResult: 'confirmed_completed', + startedAt: args.startedAt, + }), + }); + return { ...read.response, replayed: true }; + } + if (read.confirmed === 'absent') { + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: 'effect_absent', + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: args.intent, + outcome: 'failed', + reconcileResult: 'confirmed_absent', + startedAt: args.startedAt, + }), + }) + ); + throw ambiguousPrError(); + } + // `unresolved`: the provider reference read failed (network/timeout/5xx/… + // anything but a definitive NOT_FOUND). The effect's presence is unknown, + // so the row stays reconcile-pending (emitting the deterministic ambiguous + // outbox event) and the ambiguous outcome is surfaced — never a terminal + // settle from a failed read. + await markPrRowReconcilePending({ + row: args.row, + intent: args.intent, + distinctId, + startedAt: args.startedAt, + }); + throw ambiguousPrError(); +} + +/** + * Reconcilies a merge row on a same-key retry using authoritative PR state and + * the expected head lineage (P1-A-08c): + * - PR merged → settle completed and replay; + * - PR closed without a merge, or the head moved → the fenced merge never + * committed → settle failed (`confirmed_absent`) and surface a conflict; + * - PR open with the expected head sha intact → the merge never committed → + * re-execute the merge under the same row (takeover); + * - the authoritative read failed → stay reconcile-pending, surface ambiguous. + */ +async function reconcileMergePrRow(args: { + userId: string; + row: OperationLedgerRow; + startedAt: number; + owner: string; + repo: string; + number: number; + expectedHeadSha: string; + execute: (row: OperationLedgerRow) => Promise; +}): Promise> { + type MergeReconcileState = + | { kind: 'merged'; sha: string | null } + | { kind: 'closed_unmerged' } + | { kind: 'lineage_intact' } + | { kind: 'stale_head' } + | { kind: 'unresolved' }; + + let reconcile: MergeReconcileState = { kind: 'unresolved' }; + try { + reconcile = await withGitHubUserTokenRetry({ + kiloUserId: args.userId, + call: async octokit => { + const prResp = await octokit.pulls.get({ + owner: args.owner, + repo: args.repo, + pull_number: args.number, + }); + const pr = prResp.data; + if (pr.state === 'closed' && pr.merged === true) { + return { + kind: 'merged', + sha: typeof pr.merge_commit_sha === 'string' ? pr.merge_commit_sha : null, + } satisfies MergeReconcileState; + } + if (pr.state === 'closed') { + return { kind: 'closed_unmerged' } satisfies MergeReconcileState; + } + const headSha = typeof pr.head?.sha === 'string' ? pr.head.sha : null; + return ( + headSha !== null && headSha === args.expectedHeadSha + ? { kind: 'lineage_intact' } + : { kind: 'stale_head' } + ) satisfies MergeReconcileState; + }, + }); + } catch { + // A failed authoritative read — including a GitHub NOT_FOUND (PR missing, + // access revoked, or a transient API failure) — leaves the state + // `unresolved`. NOT_FOUND is a READ failure, never a confirmed + // non-merge: only explicit provider state (`closed` without `merged: + // true`) settles the row absent. + } + + const distinctId = await resolvePrDistinctId(args.userId); + switch (reconcile.kind) { + case 'merged': { + const canonical = { + merged: true, + sha: reconcile.sha ?? 'unknown', + branchDeleted: false, + }; + await settleCompletedPrRow({ + rowId: args.row.id, + canonicalResult: canonical, + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: 'merge', + outcome: 'completed', + reconcileResult: 'confirmed_completed', + startedAt: args.startedAt, + }), + }); + return { ...canonical, replayed: true } as unknown as ReplayedResult; + } + case 'closed_unmerged': + case 'stale_head': + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: reconcile.kind === 'closed_unmerged' ? 'already_closed' : 'head_moved', + outboxEvent: prSettledOutboxEvent({ + distinctId, + intent: 'merge', + outcome: 'failed', + reconcileResult: 'confirmed_absent', + startedAt: args.startedAt, + }), + }) + ); + throw conflictPrError(); + case 'lineage_intact': + return args.execute(args.row); + case 'unresolved': + // The authoritative read failed: the merge may or may not have + // committed. Mark reconcile-pending (emitting the deterministic + // ambiguous outbox event) and surface the ambiguous outcome; the row + // is NEVER settled absent from a failed read. + await markPrRowReconcilePending({ + row: args.row, + intent: 'merge', + distinctId, + startedAt: args.startedAt, + }); + throw ambiguousPrError(); + } +} + +/** + * The merge effect body shared by the legacy and ledger paths: fetch the PR to + * derive the authoritative head ref/sha/same-repo identity, merge fenced on + * `expectedHeadSha`, then best-effort delete the same-repo head branch. The + * branch-delete error text stays in the response only — it is free text and + * must never enter the ledger `canonical_result`. + */ +type MergeWriteResult = + | { merged: boolean; sha: string; branchDeleted: false } + | { merged: true; sha: string; branchDeleted: true } + | { merged: true; sha: string; branchDeleted: false; branchDeleteError: string }; + +async function runMergeWrite( + octokit: ReturnType, + input: z.infer +): Promise { + const prResp = await octokit.pulls.get({ + owner: input.owner, + repo: input.repo, + pull_number: input.number, + }); + const pr = prResp.data; + const headRepo = pr.head?.repo ?? null; + const baseRepo = pr.base?.repo ?? null; + const sameRepo = + headRepo !== null && + baseRepo !== null && + typeof headRepo.id === 'number' && + typeof baseRepo.id === 'number' && + headRepo.id === baseRepo.id; + const fetchedHeadSha = typeof pr.head?.sha === 'string' ? pr.head.sha : null; + const headRefName = typeof pr.head?.ref === 'string' ? pr.head.ref : null; + + const params = buildMergePullRequestParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + method: input.method, + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + expectedHeadSha: input.expectedHeadSha, + }); + const response = await octokit.pulls.merge(params); + const merged = Boolean(response.data.merged); + if (!merged) { + return { merged: false as const, sha: response.data.sha, branchDeleted: false as const }; + } + if ( + !input.deleteBranch || + !sameRepo || + headRefName === null || + fetchedHeadSha === null || + fetchedHeadSha !== input.expectedHeadSha + ) { + return { merged: true as const, sha: response.data.sha, branchDeleted: false as const }; + } + try { + await octokit.git.deleteRef( + buildDeleteRefParams({ + owner: input.owner, + repo: input.repo, + headRef: headRefName, + }) + ); + return { merged: true as const, sha: response.data.sha, branchDeleted: true as const }; + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Branch delete failed'; + return { + merged: true as const, + sha: response.data.sha, + branchDeleted: false as const, + branchDeleteError: message, + }; + } +} + +/** Ledger view of the merge write: declined merges never settle the row. */ +async function runMergeLedgerWrite( + octokit: ReturnType, + input: z.infer +): Promise> { + const result = await runMergeWrite(octokit, input); + if (result.merged) { + return { + kind: 'settle', + canonical: { merged: true, sha: result.sha, branchDeleted: result.branchDeleted }, + response: result, + }; + } + return { kind: 'no_settle', response: result }; +} + export const githubPrReviewRouter = createTRPCRouter({ getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { const overview = await withGitHubUserTokenRetry({ @@ -862,22 +1661,99 @@ export const githubPrReviewRouter = createTRPCRouter({ createReviewComment: baseProcedure .input(CreateReviewCommentInput) .mutation(async ({ ctx, input }) => { + if (input.operationKey === undefined) { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildCreateReviewCommentParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + body: input.body, + commitSha: input.commitSha, + path: input.path, + line: input.line, + side: input.side, + startLine: input.startLine, + startSide: input.startSide, + }); + const response = await octokit.pulls.createReviewComment(params); + return { + commentId: response.data.id, + nodeId: response.data.node_id, + }; + }, + }); + return result; + } + + const startedAt = Date.now(); + return runPrLedgerMutation({ + userId: ctx.user.id, + intent: 'create_review_comment', + operationKey: input.operationKey, + resourceKey: prLedgerResourceKey('create_review_comment', input), + startedAt, + execute: row => + executePrWriteWithLedger({ + userId: ctx.user.id, + row, + intent: 'create_review_comment', + startedAt, + runWrite: async octokit => { + const params = buildCreateReviewCommentParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + body: input.body, + commitSha: input.commitSha, + path: input.path, + line: input.line, + side: input.side, + startLine: input.startLine, + startSide: input.startSide, + }); + const response = await octokit.pulls.createReviewComment(params); + const canonical = { commentId: response.data.id, nodeId: response.data.node_id }; + return { kind: 'settle', canonical, response: { ...canonical } }; + }, + }), + reconcile: row => + reconcileCommentPrRow({ + userId: ctx.user.id, + row, + intent: 'create_review_comment', + startedAt, + providerRefOf: canonical => + typeof canonical.commentId === 'number' ? canonical.commentId : null, + readRef: async (octokit, providerId) => { + const response = await octokit.pulls.getReviewComment({ + owner: input.owner, + repo: input.repo, + comment_id: providerId, + }); + const canonical = { commentId: response.data.id, nodeId: response.data.node_id }; + return { canonical, response: { ...canonical } }; + }, + }), + }); + }), + + // Reply to an existing review comment (creates a child comment in the + // same thread). + replyToComment: baseProcedure.input(ReplyToCommentInput).mutation(async ({ ctx, input }) => { + if (input.operationKey === undefined) { const result = await withGitHubUserTokenRetry({ kiloUserId: ctx.user.id, call: async octokit => { - const params = buildCreateReviewCommentParams({ + const params = buildReplyToCommentParams({ owner: input.owner, repo: input.repo, number: input.number, + commentId: input.commentId, body: input.body, - commitSha: input.commitSha, - path: input.path, - line: input.line, - side: input.side, - startLine: input.startLine, - startSide: input.startSide, }); - const response = await octokit.pulls.createReviewComment(params); + const response = await octokit.pulls.createReplyForReviewComment(params); return { commentId: response.data.id, nodeId: response.data.node_id, @@ -885,55 +1761,134 @@ export const githubPrReviewRouter = createTRPCRouter({ }, }); return result; - }), + } - // Reply to an existing review comment (creates a child comment in the - // same thread). - replyToComment: baseProcedure.input(ReplyToCommentInput).mutation(async ({ ctx, input }) => { - const result = await withGitHubUserTokenRetry({ - kiloUserId: ctx.user.id, - call: async octokit => { - const params = buildReplyToCommentParams({ - owner: input.owner, - repo: input.repo, - number: input.number, - commentId: input.commentId, - body: input.body, - }); - const response = await octokit.pulls.createReplyForReviewComment(params); - return { - commentId: response.data.id, - nodeId: response.data.node_id, - }; - }, + const startedAt = Date.now(); + return runPrLedgerMutation({ + userId: ctx.user.id, + intent: 'reply_comment', + operationKey: input.operationKey, + resourceKey: prLedgerResourceKey('reply_comment', input), + startedAt, + execute: row => + executePrWriteWithLedger({ + userId: ctx.user.id, + row, + intent: 'reply_comment', + startedAt, + runWrite: async octokit => { + const params = buildReplyToCommentParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + commentId: input.commentId, + body: input.body, + }); + const response = await octokit.pulls.createReplyForReviewComment(params); + const canonical = { commentId: response.data.id, nodeId: response.data.node_id }; + return { kind: 'settle', canonical, response: { ...canonical } }; + }, + }), + reconcile: row => + reconcileCommentPrRow({ + userId: ctx.user.id, + row, + intent: 'reply_comment', + startedAt, + providerRefOf: canonical => + typeof canonical.commentId === 'number' ? canonical.commentId : null, + readRef: async (octokit, providerId) => { + const response = await octokit.pulls.getReviewComment({ + owner: input.owner, + repo: input.repo, + comment_id: providerId, + }); + const canonical = { commentId: response.data.id, nodeId: response.data.node_id }; + return { canonical, response: { ...canonical } }; + }, + }), }); - return result; }), // Submit a pending review with an optional batch of inline comments and // an overall event (APPROVE / REQUEST_CHANGES / COMMENT). submitReview: baseProcedure.input(SubmitReviewInput).mutation(async ({ ctx, input }) => { - const result = await withGitHubUserTokenRetry({ - kiloUserId: ctx.user.id, - call: async octokit => { - const params = buildSubmitReviewParams({ - owner: input.owner, - repo: input.repo, - number: input.number, - event: input.event, - body: input.body, - commitSha: input.commitSha, - comments: input.comments, - }); - const response = await octokit.pulls.createReview(params); - return { - reviewId: response.data.id, - nodeId: response.data.node_id, - state: response.data.state, - }; - }, + if (input.operationKey === undefined) { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildSubmitReviewParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + event: input.event, + body: input.body, + commitSha: input.commitSha, + comments: input.comments, + }); + const response = await octokit.pulls.createReview(params); + return { + reviewId: response.data.id, + nodeId: response.data.node_id, + state: response.data.state, + }; + }, + }); + return result; + } + + const startedAt = Date.now(); + return runPrLedgerMutation({ + userId: ctx.user.id, + intent: 'submit_review', + operationKey: input.operationKey, + resourceKey: prLedgerResourceKey('submit_review', input), + startedAt, + execute: row => + executePrWriteWithLedger({ + userId: ctx.user.id, + row, + intent: 'submit_review', + startedAt, + runWrite: async octokit => { + const params = buildSubmitReviewParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + event: input.event, + body: input.body, + commitSha: input.commitSha, + comments: input.comments, + }); + const response = await octokit.pulls.createReview(params); + const canonical = { reviewId: response.data.id, nodeId: response.data.node_id }; + return { + kind: 'settle', + canonical, + response: { ...canonical, state: response.data.state }, + }; + }, + }), + reconcile: row => + reconcileCommentPrRow({ + userId: ctx.user.id, + row, + intent: 'submit_review', + startedAt, + providerRefOf: canonical => + typeof canonical.reviewId === 'number' ? canonical.reviewId : null, + readRef: async (octokit, providerId) => { + const response = await octokit.pulls.getReview({ + owner: input.owner, + repo: input.repo, + pull_number: input.number, + review_id: providerId, + }); + const canonical = { reviewId: response.data.id, nodeId: response.data.node_id }; + return { canonical, response: { ...canonical, state: response.data.state } }; + }, + }), }); - return result; }), // Resolve a review thread (GraphQL — there is no REST endpoint for this). @@ -1032,87 +1987,47 @@ export const githubPrReviewRouter = createTRPCRouter({ // arbitrary same-repo ref (e.g. `main`) by spoofing `headRef`. The delete // is fenced on the server-derived head sha matching `expectedHeadSha`, // same-repo identity, and the merge actually completing. + // + // P1-A-08c: with an `operationKey`, the merge admits a `pr` row and + // reconciles same-key retries against authoritative PR state and the + // expected head lineage before ever re-merging. A declined merge + // (`merged: false`) deliberately leaves the row admitted so a later retry + // can reconcile and re-execute instead of blindly re-merging. mergePullRequest: baseProcedure.input(MergePullRequestInput).mutation(async ({ ctx, input }) => { - return withGitHubUserTokenRetry({ - kiloUserId: ctx.user.id, - call: async octokit => { - // Fetch the PR first so we know the authoritative head ref, head sha, - // and whether the head repo is the same as the base repo. A merge - // does not move the head branch, so the ref/sha derived here are - // valid for the post-merge delete decision. - const prResp = await octokit.pulls.get({ - owner: input.owner, - repo: input.repo, - pull_number: input.number, - }); - const pr = prResp.data; - const headRepo = pr.head?.repo ?? null; - const baseRepo = pr.base?.repo ?? null; - // Treat a null/absent head repo (e.g. deleted fork) as not-deletable; - // also bail if base.repo is missing for the same reason. Compare the - // numeric repo id — robust against name/owner changes. - const sameRepo = - headRepo !== null && - baseRepo !== null && - typeof headRepo.id === 'number' && - typeof baseRepo.id === 'number' && - headRepo.id === baseRepo.id; - const fetchedHeadSha = typeof pr.head?.sha === 'string' ? pr.head.sha : null; - const headRefName = typeof pr.head?.ref === 'string' ? pr.head.ref : null; - - const params = buildMergePullRequestParams({ + if (input.operationKey === undefined) { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: octokit => runMergeWrite(octokit, input), + }); + } + + const startedAt = Date.now(); + const execute = (row: OperationLedgerRow) => + executePrWriteWithLedger({ + userId: ctx.user.id, + row, + intent: 'merge', + startedAt, + runWrite: octokit => runMergeLedgerWrite(octokit, input), + }); + return runPrLedgerMutation({ + userId: ctx.user.id, + intent: 'merge', + operationKey: input.operationKey, + resourceKey: prLedgerResourceKey('merge', input), + startedAt, + execute, + reconcile: row => + reconcileMergePrRow({ + userId: ctx.user.id, + row, + startedAt, owner: input.owner, repo: input.repo, number: input.number, - method: input.method, - commitTitle: input.commitTitle, - commitMessage: input.commitMessage, expectedHeadSha: input.expectedHeadSha, - }); - const response = await octokit.pulls.merge(params); - const merged = Boolean(response.data.merged); - if ( - !merged || - !input.deleteBranch || - !sameRepo || - headRefName === null || - fetchedHeadSha === null || - fetchedHeadSha !== input.expectedHeadSha - ) { - return { - merged, - sha: response.data.sha, - branchDeleted: false as const, - }; - } - // Best-effort: only call deleteRef when the server-derived head is - // same-repo AND the head sha we fetched matches what the caller - // claimed to merge. Catch every error and surface it in the result - // instead of failing the whole mutation. - try { - await octokit.git.deleteRef( - buildDeleteRefParams({ - owner: input.owner, - repo: input.repo, - headRef: headRefName, - }) - ); - return { - merged: true as const, - sha: response.data.sha, - branchDeleted: true as const, - }; - } catch (error) { - const message = - error instanceof Error && error.message ? error.message : 'Branch delete failed'; - return { - merged: true as const, - sha: response.data.sha, - branchDeleted: false as const, - branchDeleteError: message, - }; - } - }, + execute, + }), }); }), From f3f913d5ca286114091e85c46380f83ae17bdd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 08:39:19 +0200 Subject: [PATCH 09/56] feat(security): make security and org writes retry safe --- .../hooks/use-organization-mutations.test.ts | 369 ++++++++++ .../lib/hooks/use-organization-mutations.ts | 118 +++- .../use-security-agent-mutations.test.ts | 282 ++++++++ .../lib/hooks/use-security-agent-mutations.ts | 108 ++- .../src/lib/hooks/use-security-findings.ts | 5 +- .../src/lib/security-agent/core/schemas.ts | 9 + .../router/shared-handlers.test.ts | 378 +++++++++++ .../security-agent/router/shared-handlers.ts | 503 ++++++++++++-- .../services/manual-dismiss-client.test.ts | 155 +++++ .../services/manual-dismiss-client.ts | 95 ++- .../services/manual-sync-client.test.ts | 51 +- .../services/manual-sync-client.ts | 32 +- ...organization-members-router.ledger.test.ts | 568 ++++++++++++++++ .../organization-members-router.ts | 633 ++++++++++++++++-- services/security-sync/src/index.test.ts | 172 +++++ services/security-sync/src/index.ts | 223 +++++- 16 files changed, 3522 insertions(+), 179 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-organization-mutations.test.ts create mode 100644 apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts create mode 100644 apps/web/src/routers/organizations/organization-members-router.ledger.test.ts diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts new file mode 100644 index 0000000000..1560948900 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.test.ts @@ -0,0 +1,369 @@ +// P1-A-08e wiring tests for `useOrganizationMutations`. +// +// The org surfaces (member-row action sheet, member-limit sheet) own their +// inline/toast error rendering; these tests assert the HOOK WIRING: the +// role-change and member-removal `mutationFn`s delegate to the matching +// `trpcClient.organizations.members.*.mutate`, the hoisted operation key is +// merged into the ledger-backed inputs (role change and removal only — a +// limit-only update carries no key), and the key rotation policy (real +// `isOrganizationMutationRetryable` + `mapOrganizationOperationError`) runs +// inside `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React +// ref state that needs a mounted renderer). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + isOrganizationMutationRetryable, + mapOrganizationOperationError, + organizationRemoveMemberIntentFingerprint, + organizationRoleChangeIntentFingerprint, + useOrganizationMutations, +} from './use-organization-mutations'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; +}; + +// The hook body mounts six useMutation calls in a fixed order: +// rename, invite, updateMember, removeMember, deleteInvite, updateMinimumBalanceAlert. +let capturedOptions: MutationOptions[] = []; +const membersUpdateMutateMock = vi.fn(); +const membersRemoveMutateMock = vi.fn(); +const orgUpdateMutateMock = vi.fn(); +const inviteMutateMock = vi.fn(); +const deleteInviteMutateMock = vi.fn(); +const updateMinimumBalanceAlertMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const setQueryDataMock = vi.fn(); +const getQueryDataMock = vi.fn(); +const cancelQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + capturedOptions.push(opts); + return { + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: undefined, + }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + setQueryData: (...args: unknown[]) => { + setQueryDataMock(...args); + }, + getQueryData: (...args: unknown[]) => { + getQueryDataMock(...args); + }, + cancelQueries: (...args: unknown[]) => { + cancelQueriesMock(...args); + }, + }), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + organizations: { + withMembers: { queryKey: () => ['organizations', 'withMembers'] }, + list: { queryKey: () => ['organizations', 'list'] }, + }, + }), + trpcClient: { + organizations: { + update: { mutate: (vars: unknown) => orgUpdateMutateMock(vars) }, + members: { + invite: { mutate: (vars: unknown) => inviteMutateMock(vars) }, + update: { mutate: (vars: unknown) => membersUpdateMutateMock(vars) }, + remove: { mutate: (vars: unknown) => membersRemoveMutateMock(vars) }, + deleteInvite: { mutate: (vars: unknown) => deleteInviteMutateMock(vars) }, + }, + settings: { + updateMinimumBalanceAlert: { + mutate: (vars: unknown) => updateMinimumBalanceAlertMutateMock(vars), + }, + }, + }, + }, +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { + error: (msg: string) => toastErrorMock(msg), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +const updateMemberOptions = () => capturedOptions[2]; +const removeMemberOptions = () => capturedOptions[3]; + +beforeEach(() => { + capturedOptions = []; + membersUpdateMutateMock.mockReset(); + membersRemoveMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useOrganizationMutations updateMember (P1-A-08e role branch)', () => { + it('mounts a useMutation with a custom mutationFn', () => { + useOrganizationMutations(ORG_ID); + expect(updateMemberOptions()?.mutationFn).toBeDefined(); + }); + + it('delegates a role change to members.update.mutate and resolves the result', async () => { + const result = { success: true, updated: 'role and limit' }; + membersUpdateMutateMock.mockResolvedValueOnce(result); + useOrganizationMutations(ORG_ID); + + await expect( + updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }) + ).resolves.toEqual(result); + expect(membersUpdateMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + memberId: 'member-1', + role: 'owner', + }) + ); + }); + + it('merges the hoisted operation key into a role change (P1-A-08e)', async () => { + membersUpdateMutateMock.mockResolvedValueOnce({ success: true, updated: 'role' }); + useOrganizationMutations(ORG_ID); + + await updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'billing_manager' }); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(membersUpdateMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + }); + + it('does not attach an operation key to a limit-only update (outside the ledger)', async () => { + membersUpdateMutateMock.mockResolvedValueOnce({ success: true, updated: 'limit' }); + useOrganizationMutations(ORG_ID); + + await updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', dailyUsageLimitUsd: 25 }); + + expect(hoistedKeys.getKey).not.toHaveBeenCalled(); + expect(membersUpdateMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ memberId: 'member-1', dailyUsageLimitUsd: 25 }) + ); + expect(membersUpdateMutateMock).not.toHaveBeenCalledWith( + expect.objectContaining({ operationKey: expect.any(String) }) + ); + }); + + it('regenerates the key after a successful role change (fresh intent next)', async () => { + membersUpdateMutateMock.mockResolvedValueOnce({ success: true }); + useOrganizationMutations(ORG_ID); + + await updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto retryable copy', async () => { + membersUpdateMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useOrganizationMutations(ORG_ID); + + await expect( + updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }) + ).rejects.toMatchObject({ + message: 'This change is still being processed. Please try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on a retryable network failure (the ledger owns the retry)', async () => { + membersUpdateMutateMock.mockRejectedValueOnce(new Error('Network request failed')); + useOrganizationMutations(ORG_ID); + + await expect( + updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }) + ).rejects.toMatchObject({ message: 'Network request failed' }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on the settle-failed marker (same-key retry repairs by read-back)', async () => { + membersUpdateMutateMock.mockRejectedValueOnce( + new Error('The action completed, but we could not record the result. Please try again.') + ); + useOrganizationMutations(ORG_ID); + + await expect( + updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }) + ).rejects.toMatchObject({ + message: 'The action completed, but we could not record the result. Please try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('This action did not complete. Please try again.'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + membersUpdateMutateMock.mockRejectedValueOnce(badRequest); + useOrganizationMutations(ORG_ID); + + await expect( + updateMemberOptions()?.mutationFn?.({ memberId: 'member-1', role: 'owner' }) + ).rejects.toMatchObject({ message: 'This action did not complete. Please try again.' }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); +}); + +describe('useOrganizationMutations removeMember (P1-A-08e)', () => { + it('delegates the removal to members.remove.mutate with the hoisted key', async () => { + membersRemoveMutateMock.mockResolvedValueOnce({ success: true, updated: 'member-1' }); + useOrganizationMutations(ORG_ID); + + await expect(removeMemberOptions()?.mutationFn?.({ memberId: 'member-1' })).resolves.toEqual({ + success: true, + updated: 'member-1', + }); + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(membersRemoveMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + memberId: 'member-1', + operationKey: 'hoisted-op-key', + }) + ); + }); + + it('regenerates the key after a successful removal', async () => { + membersRemoveMutateMock.mockResolvedValueOnce({ success: true }); + useOrganizationMutations(ORG_ID); + + await removeMemberOptions()?.mutationFn?.({ memberId: 'member-1' }); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT (retryable) and maps the marker', async () => { + membersRemoveMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useOrganizationMutations(ORG_ID); + + await expect( + removeMemberOptions()?.mutationFn?.({ memberId: 'member-1' }) + ).rejects.toMatchObject({ + message: 'This change is still being processed. Please try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable rejection', async () => { + const forbidden = new Error('no permission'); + Object.assign(forbidden, { data: { code: 'FORBIDDEN' } }); + membersRemoveMutateMock.mockRejectedValueOnce(forbidden); + useOrganizationMutations(ORG_ID); + + await expect( + removeMemberOptions()?.mutationFn?.({ memberId: 'member-1' }) + ).rejects.toMatchObject({ message: 'no permission' }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('onError still surfaces the mapped message through the existing toast path', () => { + useOrganizationMutations(ORG_ID); + const mapped = mapOrganizationOperationError(new Error('operation_in_progress')); + removeMemberOptions()?.onError?.(mapped); + expect(toastErrorMock).toHaveBeenCalledWith( + 'This change is still being processed. Please try again.' + ); + }); +}); + +describe('organizationRoleChangeIntentFingerprint (P1-A-08e changed-input)', () => { + it('stays stable for a retry of the same target+role and rotates when any intent input changes', () => { + const original = organizationRoleChangeIntentFingerprint(ORG_ID, 'member-1', 'owner'); + expect(organizationRoleChangeIntentFingerprint(ORG_ID, 'member-1', 'owner')).toBe(original); + + expect(organizationRoleChangeIntentFingerprint(ORG_ID, 'member-1', 'member')).not.toBe( + original + ); + expect(organizationRoleChangeIntentFingerprint(ORG_ID, 'member-2', 'owner')).not.toBe(original); + expect(organizationRoleChangeIntentFingerprint('other-org', 'member-1', 'owner')).not.toBe( + original + ); + }); +}); + +describe('organizationRemoveMemberIntentFingerprint (P1-A-08e changed-input)', () => { + it('stays stable for a retry of the same member and rotates when the member or org changes', () => { + const original = organizationRemoveMemberIntentFingerprint(ORG_ID, 'member-1'); + expect(organizationRemoveMemberIntentFingerprint(ORG_ID, 'member-1')).toBe(original); + + expect(organizationRemoveMemberIntentFingerprint(ORG_ID, 'member-2')).not.toBe(original); + expect(organizationRemoveMemberIntentFingerprint('other-org', 'member-1')).not.toBe(original); + }); +}); + +describe('isOrganizationMutationRetryable (P1-A-08e key-rotation policy)', () => { + it('keeps the key on retryable ledger outcomes (in-progress, settle-failed)', () => { + expect(isOrganizationMutationRetryable(new Error('operation_in_progress'))).toBe(true); + expect( + isOrganizationMutationRetryable( + new Error('The action completed, but we could not record the result. Please try again.') + ) + ).toBe(true); + }); + + it('keeps the key on generic retryable failures', () => { + expect(isOrganizationMutationRetryable(new Error('Network request failed'))).toBe(true); + const server = new Error('boom'); + Object.assign(server, { data: { code: 'INTERNAL_SERVER_ERROR' } }); + expect(isOrganizationMutationRetryable(server)).toBe(true); + }); + + it('regenerates the key on non-retryable markers and typed rejections', () => { + const replayFailed = new Error('This action did not complete. Please try again.'); + Object.assign(replayFailed, { data: { code: 'BAD_REQUEST' } }); + expect(isOrganizationMutationRetryable(replayFailed)).toBe(false); + expect(isOrganizationMutationRetryable(new Error('operation_key_reuse_mismatch'))).toBe(false); + + const forbidden = new Error('no permission'); + Object.assign(forbidden, { data: { code: 'FORBIDDEN' } }); + expect(isOrganizationMutationRetryable(forbidden)).toBe(false); + }); + + it('keeps the key on NOT_FOUND (same-key retry replays the typed rejection and then rotates)', () => { + // Mirrors the PR ledger policy: NOT_FOUND is a generic retryable code, so + // the key survives; the next same-key retry hits the settled-failed row + // and rotates on the replay-failed BAD_REQUEST instead. + const notFound = new Error('User is not a member of this organization'); + Object.assign(notFound, { data: { code: 'NOT_FOUND' } }); + expect(isOrganizationMutationRetryable(notFound)).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts index 2b6b9afa16..84e3c99541 100644 --- a/apps/mobile/src/lib/hooks/use-organization-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts @@ -6,12 +6,82 @@ import { type OrgRole, type OrgWithMembers, } from '@/lib/hooks/use-organization-queries'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { trpcClient, useTRPC } from '@/lib/trpc'; const onMutationError = (error: { message: string }) => { announcingToast.error(error.message || 'Something went wrong'); }; +// P1-A-08e ledger markers (server contract, mirrored from +// organization-members-router.ts). The role-change and member-removal +// mutations carry an optional `operationKey`; these hooks hoist one key per +// intent so retries of the SAME intent dedupe/replay/conflict on the server. +// Only `operation_in_progress` is a raw marker — the server sends user-facing +// copy for every other ledger outcome, so it is the only one translated here. +const ORG_OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const ORG_OPERATION_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const ORG_OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +const ORG_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; + +/** In-progress surface copy: reads like the existing retryable toasts. */ +const ORG_OPERATION_IN_PROGRESS_COPY = 'This change is still being processed. Please try again.'; + +/** + * True when the mutation may be retried under the SAME operation key. + * Retryable: `operation_in_progress`, the settle-failed marker (a same-key + * retry repairs the committed write by read-back), and generic transient + * errors. Non-retryable: the replay-failed marker (the row settled `failed`), + * the cross-intent key-reuse rejection, and typed validation/permission + * errors — the next submit must be a fresh intent with a fresh key. + */ +export function isOrganizationMutationRetryable(error: unknown): boolean { + if (error instanceof Error) { + if (error.message === ORG_OPERATION_KEY_REUSE_MISMATCH_MESSAGE) { + return false; + } + if (error.message === ORG_OPERATION_REPLAY_FAILED_MESSAGE) { + return false; + } + if (error.message === ORG_LEDGER_SETTLE_FAILED_MESSAGE) { + return true; + } + } + return classifyPrReviewMutationError(error).kind === 'retryable'; +} + +/** Maps the raw in-progress marker onto retryable copy; other errors pass through. */ +export function mapOrganizationOperationError(error: unknown): unknown { + if (error instanceof Error && error.message === ORG_OPERATION_IN_PROGRESS_MESSAGE) { + return new Error(ORG_OPERATION_IN_PROGRESS_COPY); + } + return error; +} + +/** + * Deterministic intent fingerprint for a role change. Every intent-defining + * input is included: a retry of the SAME target+role reuses the hoisted key, + * and any change (different member or role) rotates it so the ledger treats + * the submit as a fresh intent. + */ +export function organizationRoleChangeIntentFingerprint( + organizationId: string, + memberId: string, + role: OrgRole +): string { + return JSON.stringify({ resource: [organizationId, memberId], role }); +} + +/** Deterministic intent fingerprint for a member removal. */ +export function organizationRemoveMemberIntentFingerprint( + organizationId: string, + memberId: string +): string { + return JSON.stringify({ resource: [organizationId, memberId] }); +} + type UseOrganizationMutationsOptions = { /** * member-limit-sheet renders `updateMember` errors inline (Pattern P2) and @@ -29,6 +99,7 @@ export function useOrganizationMutations( ) { const trpc = useTRPC(); const queryClient = useQueryClient(); + const { getKey, rotateKey } = useHoistedOperationKey(); const withMembersKey = trpc.organizations.withMembers.queryKey({ organizationId }); const listKey = trpc.organizations.list.queryKey(); @@ -130,12 +201,32 @@ export function useOrganizationMutations( }), updateMember: useMutation({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - mutationFn: (input: { + mutationFn: async (input: { memberId: string; role?: OrgRole; dailyUsageLimitUsd?: number | null; - }) => trpcClient.organizations.members.update.mutate({ organizationId, ...input }), + }) => { + try { + const result = await trpcClient.organizations.members.update.mutate({ + organizationId, + ...input, + // Only the role branch is ledger-backed (P1-A-08e). A limit-only + // request carries no key — the server keeps its existing path. + ...(input.role !== undefined && { + operationKey: getKey( + organizationRoleChangeIntentFingerprint(organizationId, input.memberId, input.role) + ), + }), + }); + rotateKey(); + return result; + } catch (error) { + if (!isOrganizationMutationRetryable(error)) { + rotateKey(); + } + throw mapOrganizationOperationError(error); + } + }, ...optimistic<{ memberId: string; role?: OrgRole; dailyUsageLimitUsd?: number | null }>( (old, input) => ({ ...old, @@ -156,9 +247,24 @@ export function useOrganizationMutations( }), removeMember: useMutation({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - mutationFn: (input: { memberId: string }) => - trpcClient.organizations.members.remove.mutate({ organizationId, ...input }), + mutationFn: async (input: { memberId: string }) => { + try { + const result = await trpcClient.organizations.members.remove.mutate({ + organizationId, + ...input, + operationKey: getKey( + organizationRemoveMemberIntentFingerprint(organizationId, input.memberId) + ), + }); + rotateKey(); + return result; + } catch (error) { + if (!isOrganizationMutationRetryable(error)) { + rotateKey(); + } + throw mapOrganizationOperationError(error); + } + }, ...optimistic<{ memberId: string }>((old, input) => ({ ...old, members: old.members.filter( diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts new file mode 100644 index 0000000000..0328dfe959 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.test.ts @@ -0,0 +1,282 @@ +// P1-A-08e wiring tests for `useTriggerSecuritySync`. +// +// The dashboard owns the sync button and its toasts; these tests assert the +// HOOK WIRING: the `mutationFn` delegates to the matching +// `trpcClient.(organizations.)securityAgent.triggerSync.mutate`, the hoisted +// operation key is merged into the input, and the key rotation policy (real +// `isSecuritySyncRetryable` + `mapSecuritySyncOperationError`) runs inside +// `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React ref +// state that needs a mounted renderer). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + isSecuritySyncRetryable, + mapSecuritySyncOperationError, + securitySyncIntentFingerprint, + useTriggerSecuritySync, +} from './use-security-agent-mutations'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); + +vi.mock('@kilocode/app-shared/security-agent', () => ({ + isPersonalSecurityScope: (scope: string) => scope === 'personal', +})); + +vi.mock('@/lib/hooks/use-security-agent-commands', () => ({ + trackSecurityAgentCommand: trackCommandMock, +})); + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown) => void; + onSuccess?: (result: unknown, vars: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; +}; + +let lastCapturedOptions: MutationOptions | null = null; +const personalTriggerSyncMutateMock = vi.fn(); +const orgTriggerSyncMutateMock = vi.fn(); +const invalidateQueriesMock = vi.fn(); +const toastErrorMock = vi.fn(); +const trackCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false }; + }, + useQueryClient: () => ({ + invalidateQueries: (...args: unknown[]) => { + invalidateQueriesMock(...args); + }, + }), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + securityAgent: { getConfig: { queryKey: () => ['securityAgent', 'getConfig'] } }, + }), + trpcClient: { + securityAgent: { + triggerSync: { mutate: (vars: unknown) => personalTriggerSyncMutateMock(vars) }, + }, + organizations: { + securityAgent: { + triggerSync: { mutate: (vars: unknown) => orgTriggerSyncMutateMock(vars) }, + }, + }, + }, +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { + error: (msg: string) => toastErrorMock(msg), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +describe('useTriggerSecuritySync (P1-A-08e wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + personalTriggerSyncMutateMock.mockReset(); + orgTriggerSyncMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + trackCommandMock.mockClear(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mounts a useMutation with a custom mutationFn', () => { + useTriggerSecuritySync('personal'); + expect(lastCapturedOptions?.mutationFn).toBeDefined(); + }); + + it('delegates a personal sync to securityAgent.triggerSync.mutate with the hoisted key', async () => { + const result = { success: true, accepted: true, commandId: 'cmd-1' }; + personalTriggerSyncMutateMock.mockResolvedValueOnce(result); + useTriggerSecuritySync('personal'); + + await expect(lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' })).resolves.toEqual( + result + ); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(personalTriggerSyncMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ repoFullName: 'kilo/repo', operationKey: 'hoisted-op-key' }) + ); + }); + + it('delegates an org sync to organizations.securityAgent.triggerSync.mutate with the key', async () => { + orgTriggerSyncMutateMock.mockResolvedValueOnce({ success: true, commandId: 'cmd-2' }); + useTriggerSecuritySync(ORG_ID); + + await lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }); + + expect(orgTriggerSyncMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + repoFullName: 'kilo/repo', + operationKey: 'hoisted-op-key', + }) + ); + }); + + it('regenerates the key after a successful sync (fresh intent next)', async () => { + personalTriggerSyncMutateMock.mockResolvedValueOnce({ success: true, commandId: 'cmd-1' }); + useTriggerSecuritySync('personal'); + + await lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and maps it onto retryable copy', async () => { + personalTriggerSyncMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useTriggerSecuritySync('personal'); + + await expect( + lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }) + ).rejects.toMatchObject({ + message: 'A security sync is already in progress. Please try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on the ambiguous outcome (same-key retry reconciles)', async () => { + personalTriggerSyncMutateMock.mockRejectedValueOnce( + new Error("Couldn't confirm — check the security review before retrying.") + ); + useTriggerSecuritySync('personal'); + + await expect( + lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }) + ).rejects.toMatchObject({ + message: "Couldn't confirm — check the security review before retrying.", + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on a retryable network failure (the ledger owns the retry)', async () => { + personalTriggerSyncMutateMock.mockRejectedValueOnce(new Error('Network request failed')); + useTriggerSecuritySync('personal'); + + await expect( + lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }) + ).rejects.toMatchObject({ message: 'Network request failed' }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (replay-failed ends the intent)', async () => { + const replayFailed = new Error('This action did not complete. Please try again.'); + Object.assign(replayFailed, { data: { code: 'BAD_REQUEST' } }); + personalTriggerSyncMutateMock.mockRejectedValueOnce(replayFailed); + useTriggerSecuritySync('personal'); + + await expect( + lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }) + ).rejects.toMatchObject({ message: 'This action did not complete. Please try again.' }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('regenerates the key on the persistence-failure marker even though it is INTERNAL_SERVER_ERROR', async () => { + const persistenceFailed = new Error('We could not record this action. Please try again later.'); + Object.assign(persistenceFailed, { data: { code: 'INTERNAL_SERVER_ERROR' } }); + personalTriggerSyncMutateMock.mockRejectedValueOnce(persistenceFailed); + useTriggerSecuritySync('personal'); + + await expect( + lastCapturedOptions?.mutationFn?.({ repoFullName: 'kilo/repo' }) + ).rejects.toMatchObject({ + message: 'We could not record this action. Please try again later.', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('onError toasts the mapped message through the existing toast path', () => { + useTriggerSecuritySync('personal'); + lastCapturedOptions?.onError?.( + mapSecuritySyncOperationError(new Error('operation_in_progress')) + ); + expect(toastErrorMock).toHaveBeenCalledWith( + 'A security sync is already in progress. Please try again.' + ); + }); + + it('onSuccess tracks the accepted command', () => { + useTriggerSecuritySync('personal'); + lastCapturedOptions?.onSuccess?.( + { success: true, commandId: 'cmd-9' }, + { repoFullName: 'kilo/repo' } + ); + expect(trackCommandMock).toHaveBeenCalled(); + }); +}); + +describe('securitySyncIntentFingerprint (P1-A-08e changed-input)', () => { + it('stays stable for a retry of the same scope+repo and rotates when the repo or scope changes', () => { + const original = securitySyncIntentFingerprint(ORG_ID, 'kilo/repo'); + expect(securitySyncIntentFingerprint(ORG_ID, 'kilo/repo')).toBe(original); + + expect(securitySyncIntentFingerprint(ORG_ID, 'kilo/other')).not.toBe(original); + expect(securitySyncIntentFingerprint(ORG_ID, undefined)).not.toBe(original); + expect(securitySyncIntentFingerprint('personal', 'kilo/repo')).not.toBe(original); + }); +}); + +describe('isSecuritySyncRetryable (P1-A-08e key-rotation policy)', () => { + it('keeps the key on retryable ledger outcomes (in-progress, ambiguous, settle-failed)', () => { + expect(isSecuritySyncRetryable(new Error('operation_in_progress'))).toBe(true); + expect( + isSecuritySyncRetryable( + new Error("Couldn't confirm — check the security review before retrying.") + ) + ).toBe(true); + expect( + isSecuritySyncRetryable( + new Error('The action completed, but we could not record the result. Please try again.') + ) + ).toBe(true); + }); + + it('keeps the key on generic retryable failures', () => { + expect(isSecuritySyncRetryable(new Error('Network request failed'))).toBe(true); + }); + + it('regenerates the key on non-retryable markers and typed rejections', () => { + expect( + isSecuritySyncRetryable(new Error('We could not record this action. Please try again later.')) + ).toBe(false); + expect(isSecuritySyncRetryable(new Error('operation_key_reuse_mismatch'))).toBe(false); + const replayFailed = new Error('This action did not complete. Please try again.'); + Object.assign(replayFailed, { data: { code: 'BAD_REQUEST' } }); + expect(isSecuritySyncRetryable(replayFailed)).toBe(false); + const forbidden = new Error('no permission'); + Object.assign(forbidden, { data: { code: 'FORBIDDEN' } }); + expect(isSecuritySyncRetryable(forbidden)).toBe(false); + const repoUnknown = new Error('Repository not found in your GitHub integration'); + Object.assign(repoUnknown, { data: { code: 'PRECONDITION_FAILED' } }); + expect(isSecuritySyncRetryable(repoUnknown)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts index a3f0cd6548..f77ae6de15 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -3,10 +3,84 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { type SecurityAgentConfig, type SecurityAgentConfigPatch } from '@/lib/security-agent'; import { trpcClient, useTRPC } from '@/lib/trpc'; import { pick } from '@/lib/utils'; +// P1-A-08e ledger markers (server contract, mirrored from +// shared-handlers.ts). The manual-sync command carries an optional +// `operationKey`; `useTriggerSecuritySync` hoists one key per intent so +// retries of the SAME intent dedupe/replay/conflict on the server. Only +// `operation_in_progress` is a raw marker — the server sends user-facing copy +// for every other ledger outcome, so it is the only one translated here. +const SECURITY_OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const SECURITY_OPERATION_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const SECURITY_OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +// The ambiguous transport outcome: the Worker may have accepted the command. +// Retryable under the SAME key — the server reconciles instead of +// re-submitting blind. +const SECURITY_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the security review before retrying."; +// A provider-confirmed outcome whose settle failed: a same-key retry +// re-submits and re-records the acceptance. Retryable. +const SECURITY_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; +// The ambiguous outcome could NOT be recorded as reconcile-pending, so the +// same-key retry guarantee does not hold. Non-retryable: the next submit must +// be a fresh intent with a fresh key. +const SECURITY_LEDGER_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +/** In-progress surface copy: reads like the existing retryable toasts. */ +const SECURITY_SYNC_IN_PROGRESS_COPY = 'A security sync is already in progress. Please try again.'; + +/** + * True when the sync may be retried under the SAME operation key. Retryable: + * `operation_in_progress`, the ambiguous outcome (reconcile-pending), the + * settle-failed marker, and generic transient errors. Non-retryable: the + * replay-failed marker, the persistence-failure marker (the reconcile-pending + * guarantee does not hold), the cross-intent key-reuse rejection, and typed + * validation/permission errors — the next submit must be a fresh intent. + */ +export function isSecuritySyncRetryable(error: unknown): boolean { + if (error instanceof Error) { + if (error.message === SECURITY_OPERATION_KEY_REUSE_MISMATCH_MESSAGE) { + return false; + } + if (error.message === SECURITY_OPERATION_REPLAY_FAILED_MESSAGE) { + return false; + } + if (error.message === SECURITY_LEDGER_PERSISTENCE_FAILED_MESSAGE) { + return false; + } + if ( + error.message === SECURITY_AMBIGUOUS_MESSAGE || + error.message === SECURITY_LEDGER_SETTLE_FAILED_MESSAGE + ) { + return true; + } + } + return classifyPrReviewMutationError(error).kind === 'retryable'; +} + +/** Maps the raw in-progress marker onto retryable copy; other errors pass through. */ +export function mapSecuritySyncOperationError(error: unknown): unknown { + if (error instanceof Error && error.message === SECURITY_OPERATION_IN_PROGRESS_MESSAGE) { + return new Error(SECURITY_SYNC_IN_PROGRESS_COPY); + } + return error; +} + +/** + * Deterministic intent fingerprint for a manual sync. A retry of the SAME + * scope+repo reuses the hoisted key; changing the repo (or the scope) rotates + * it so the ledger treats the submit as a fresh intent. + */ +export function securitySyncIntentFingerprint(scope: string, repoFullName?: string): string { + return JSON.stringify({ resource: [scope], repoFullName }); +} + // Split out of use-security-agent.ts (mutations only) to stay under the // 300-line file limit — these are the write-side hooks, kept alongside the // query-key helper they share. @@ -156,21 +230,37 @@ export function useSetSecurityAgentEnabled(scope: string) { export function useTriggerSecuritySync(scope: string) { const queryClient = useQueryClient(); + const { getKey, rotateKey } = useHoistedOperationKey(); return useMutation({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - mutationFn: (vars: Parameters[0] = {}) => - isPersonalSecurityScope(scope) - ? trpcClient.securityAgent.triggerSync.mutate(vars) - : trpcClient.organizations.securityAgent.triggerSync.mutate({ - organizationId: scope, - ...vars, - }), + mutationFn: async ( + vars: Parameters[0] = {} + ) => { + const operationKey = getKey(securitySyncIntentFingerprint(scope, vars.repoFullName)); + try { + const result = isPersonalSecurityScope(scope) + ? await trpcClient.securityAgent.triggerSync.mutate({ ...vars, operationKey }) + : await trpcClient.organizations.securityAgent.triggerSync.mutate({ + organizationId: scope, + ...vars, + operationKey, + }); + rotateKey(); + return result; + } catch (error) { + if (!isSecuritySyncRetryable(error)) { + rotateKey(); + } + throw mapSecuritySyncOperationError(error); + } + }, onError: error => { announcingToast.error(error.message); }, onSuccess: result => { - trackSecurityAgentCommand(queryClient, scope, result.commandId); + if (result.commandId) { + trackSecurityAgentCommand(queryClient, scope, result.commandId); + } }, }); } diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index d22ceac9b9..b832485b1f 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Security finding write hooks stay in one module; the dismiss commandId guard is one line over the cap. */ import { getNextSecurityFindingsOffset, getRemediationUnavailableCopy, @@ -106,7 +107,9 @@ export function useDismissSecurityFinding(scope: string) { ...vars, }), onSuccess: result => { - trackSecurityAgentCommand(queryClient, scope, result.commandId); + if (result.commandId) { + trackSecurityAgentCommand(queryClient, scope, result.commandId); + } }, }); } diff --git a/apps/web/src/lib/security-agent/core/schemas.ts b/apps/web/src/lib/security-agent/core/schemas.ts index 3d58aae693..15714bfcd6 100644 --- a/apps/web/src/lib/security-agent/core/schemas.ts +++ b/apps/web/src/lib/security-agent/core/schemas.ts @@ -96,12 +96,21 @@ export const ListFindingsInputSchema = z.object({ export const TriggerSyncInputSchema = z.object({ repoFullName: z.string().optional(), + /** + * Optional client-generated per-intent key (P1-A-08e). When present, the + * handler admits a `security`-domain operation ledger row before submitting + * to the Worker, so retries of the same intent dedupe/replay/conflict and + * the Worker joins the terminal outcome by the stored `provider_ref`. + */ + operationKey: z.string().min(1).max(128).optional(), }); export const DismissFindingInputSchema = z.object({ findingId: z.string().uuid(), reason: DismissReasonSchema, comment: z.string().optional(), + /** Optional client-generated per-intent key (P1-A-08e); see TriggerSyncInputSchema. */ + operationKey: z.string().min(1).max(128).optional(), }); export const GetFindingInputSchema = z.object({ diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts index 115e973064..4877675438 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.test.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.test.ts @@ -1,9 +1,11 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { TRPCError } from '@trpc/server'; import type { createSecurityAgentHandlers as createSecurityAgentHandlersType } from './shared-handlers'; import type * as manualSyncClientModule from '../services/manual-sync-client'; import type * as manualDismissClientModule from '../services/manual-dismiss-client'; import type * as manualAnalysisClientModule from '../services/manual-analysis-client'; import type * as manualRemediationClientModule from '../services/manual-remediation-client'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; const commandId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const mockSubmitManualSecuritySync = jest.fn() as jest.MockedFunction< @@ -51,6 +53,11 @@ const mockAutoDismissEligibleFindings = actor: unknown ) => Promise<{ dismissed: number; skipped: number; errors: number }> >(); +const mockAdmitOperation = jest.fn<(...args: unknown[]) => Promise>(); +const mockMarkReconcilePending = jest.fn<(...args: unknown[]) => Promise>(); +const mockRecordOperationProgress = jest.fn<(...args: unknown[]) => Promise>(); +const mockSetOperationProviderRef = jest.fn<(...args: unknown[]) => Promise>(); +const mockSettleOperation = jest.fn<(...args: unknown[]) => Promise>(); jest.mock('../services/manual-sync-client', () => ({ submitManualSecuritySync: mockSubmitManualSecuritySync, @@ -66,6 +73,13 @@ jest.mock('../services/manual-remediation-client', () => ({ submitManualRemediationStart: mockSubmitManualRemediationStart, submitRemediationCancellation: mockSubmitRemediationCancellation, })); +jest.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: mockAdmitOperation, + markReconcilePending: mockMarkReconcilePending, + recordOperationProgress: mockRecordOperationProgress, + setOperationProviderRef: mockSetOperationProviderRef, + settleOperation: mockSettleOperation, +})); jest.mock('../github/permissions', () => ({ hasSecurityReviewPermissions: () => true, getReauthorizeUrl: jest.fn(), @@ -142,6 +156,10 @@ beforeEach(() => { mockGetRemediationAttemptHistory.mockResolvedValue([]); mockEnqueueBacklogFindings.mockResolvedValue(0); mockCheckDependabotAlertsAvailability.mockResolvedValue([]); + mockRecordOperationProgress.mockResolvedValue({}); + mockSetOperationProviderRef.mockResolvedValue({}); + mockMarkReconcilePending.mockResolvedValue({}); + mockSettleOperation.mockResolvedValue({ settled: true }); }); function createHandlers() { @@ -629,6 +647,366 @@ describe('queue-backed handlers', () => { }); }); +describe('security operation ledger (P1-A-08e)', () => { + const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const commandId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const runId = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + const messageId = 'manual-sync-message-123'; + const operationKey = 'retry-safe-key-123'; + const accepted = { accepted: true as const, commandId, runId, messageId }; + + function ledgerRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: 'ledger-row-id', + operation_key: operationKey, + domain: 'security', + intent: 'manual_sync', + kilo_user_id: 'user-123', + organization_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + resource_key: `security:manual_sync:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:kilo/repo`, + provider_ref: null, + taxonomy: 'reconcile-first', + status: 'admitted', + outcome_code: null, + canonical_result: null, + admitted_at: '2026-06-17T10:00:00.000Z', + settled_at: null, + lease_expires_at: '2026-06-17T10:02:00.000Z', + expires_at: '2026-07-17T10:00:00.000Z', + ...overrides, + }; + } + + it('admits a manual sync before submission and durably records the provider reference', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockResolvedValue(accepted); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).resolves.toEqual({ success: true, ...accepted }); + + expect(mockAdmitOperation.mock.calls[0]?.[1]).toMatchObject({ + userId: 'user-123', + orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + domain: 'security', + intent: 'manual_sync', + operationKey, + resourceKey: `security:manual_sync:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:kilo/repo`, + taxonomy: 'reconcile-first', + }); + expect(mockSetOperationProviderRef.mock.calls[0]?.[1]).toEqual({ + rowId: 'ledger-row-id', + providerRef: messageId, + }); + expect(mockRecordOperationProgress.mock.calls[0]?.[1]).toBe('ledger-row-id'); + expect(mockRecordOperationProgress.mock.calls[0]?.[2]).toEqual({ commandId, runId, messageId }); + expect(mockSubmitManualSecuritySync).toHaveBeenCalledTimes(1); + }); + + it('replays a settled manual sync without re-submitting or re-tracking', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_settled', + row: ledgerRow({ + status: 'completed', + canonical_result: { commandId, runId, messageId }, + }), + }); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).resolves.toEqual({ + success: true, + accepted: true, + commandId, + runId, + messageId, + replayed: true, + }); + + expect(mockSubmitManualSecuritySync).not.toHaveBeenCalled(); + expect(mockSetOperationProviderRef).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('conflicts on an in-flight manual sync instead of re-submitting', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_in_flight', + row: ledgerRow(), + }); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'operation_in_progress' }); + expect(mockSubmitManualSecuritySync).not.toHaveBeenCalled(); + }); + + it('conflicts when a reconcile retry is already in progress', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_reconcile_in_progress', + row: ledgerRow({ status: 'reconcile_pending' }), + }); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'operation_in_progress' }); + expect(mockSubmitManualSecuritySync).not.toHaveBeenCalled(); + }); + + it('re-submits a manual sync takeover under the same key', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'takeover', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockResolvedValue(accepted); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).resolves.toEqual({ success: true, ...accepted }); + expect(mockSubmitManualSecuritySync).toHaveBeenCalledTimes(1); + expect(mockSetOperationProviderRef.mock.calls[0]?.[1]).toEqual({ + rowId: 'ledger-row-id', + providerRef: messageId, + }); + }); + + it('settles the row failed on a definitive pre-acceptance rejection', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockRejectedValue( + new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Security sync service request failed (status 400).', + }) + ); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toThrow('status 400'); + + const settleInput = mockSettleOperation.mock.calls[0]?.[1] as { + rowId: string; + status: string; + outcomeCode: string; + outboxEvent?: { eventName: string; properties: { outcome: string; intent: string } }; + }; + expect(settleInput).toMatchObject({ + rowId: 'ledger-row-id', + status: 'failed', + outcomeCode: 'pre_acceptance_rejected', + }); + expect(settleInput?.outboxEvent?.eventName).toBe('security_command_settled'); + expect(settleInput?.outboxEvent?.properties).toMatchObject({ + intent: 'manual_sync', + outcome: 'failed', + }); + expect(mockMarkReconcilePending).not.toHaveBeenCalled(); + }); + + it('marks the row reconcile_pending on ambiguous transport and surfaces a retryable conflict', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockRejectedValue( + new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Could not reach the security sync service. Try again.', + }) + ); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: "Couldn't confirm — check the security review before retrying.", + }); + + expect(mockMarkReconcilePending.mock.calls[0]?.[1]).toMatchObject({ + rowId: 'ledger-row-id', + }); + const reconcileCall = mockMarkReconcilePending.mock.calls[0]?.[1] as { + outboxEvent?: { eventName: string; properties: { outcome: string; intent: string } }; + }; + expect(reconcileCall?.outboxEvent?.eventName).toBe('security_command_settled'); + expect(reconcileCall?.outboxEvent?.properties).toMatchObject({ + intent: 'manual_sync', + outcome: 'ambiguous', + }); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('rejects cross-intent key reuse before honoring any outcome', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ intent: 'dismiss_finding' }), + }); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_key_reuse_mismatch', + }); + expect(mockSubmitManualSecuritySync).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('never returns a success receipt when the acceptance cannot be recorded', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockResolvedValue(accepted); + mockSetOperationProviderRef.mockRejectedValue(new Error('database unavailable')); + + let captured: unknown; + try { + await createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }); + throw new Error('expected rejection'); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('INTERNAL_SERVER_ERROR'); + expect((captured as TRPCError).message).toBe( + 'The action completed, but we could not record the result. Please try again.' + ); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('surfaces a distinct persistence error when the reconcile-pending write fails', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockSubmitManualSecuritySync.mockRejectedValue( + new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Could not reach the security sync service. Try again.', + }) + ); + mockMarkReconcilePending.mockRejectedValue(new Error('database unavailable')); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'We could not record this action. Please try again later.', + }); + expect(mockSettleOperation).not.toHaveBeenCalled(); + }); + + it('admits a finding dismissal with the dismissal resource key and records the provider reference', async () => { + mockGetSecurityFindingById.mockResolvedValue({ + id: findingId, + source: 'dependabot', + severity: 'high', + }); + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'dismiss_finding', + resource_key: `security:dismiss_finding:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:${findingId}`, + }), + }); + mockSubmitManualFindingDismissal.mockResolvedValue({ + ...accepted, + messageId: 'dismiss-message-123', + }); + + await expect( + createHandlers().dismissFinding.handler({ + ctx: context, + input: { findingId, reason: 'not_used', operationKey }, + }) + ).resolves.toEqual({ + success: true, + accepted: true, + commandId, + runId, + messageId: 'dismiss-message-123', + }); + + expect(mockAdmitOperation.mock.calls[0]?.[1]).toMatchObject({ + intent: 'dismiss_finding', + operationKey, + resourceKey: `security:dismiss_finding:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:${findingId}`, + }); + expect(mockSetOperationProviderRef.mock.calls[0]?.[1]).toEqual({ + rowId: 'ledger-row-id', + providerRef: 'dismiss-message-123', + }); + expect(mockSubmitManualFindingDismissal).toHaveBeenCalledTimes(1); + }); + + it('replays a settled dismissal without re-triggering the GitHub call', async () => { + mockGetSecurityFindingById.mockResolvedValue({ + id: findingId, + source: 'dependabot', + severity: 'high', + }); + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_settled', + row: ledgerRow({ + intent: 'dismiss_finding', + resource_key: `security:dismiss_finding:org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:${findingId}`, + status: 'completed', + canonical_result: { commandId, runId, messageId: 'dismiss-message-123' }, + }), + }); + + await expect( + createHandlers().dismissFinding.handler({ + ctx: context, + input: { findingId, reason: 'not_used', operationKey }, + }) + ).resolves.toEqual({ + success: true, + accepted: true, + commandId, + runId, + messageId: 'dismiss-message-123', + replayed: true, + }); + expect(mockSubmitManualFindingDismissal).not.toHaveBeenCalled(); + }); + + it('rejects a replay of a settled failed row as non-retryable', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_settled', + row: ledgerRow({ status: 'failed', outcome_code: 'pre_acceptance_rejected' }), + }); + + await expect( + createHandlers().triggerSync.handler({ + ctx: context, + input: { repoFullName: 'kilo/repo', operationKey }, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'This action did not complete. Please try again.', + }); + expect(mockSubmitManualSecuritySync).not.toHaveBeenCalled(); + }); +}); + describe('remediation action tracking', () => { const findingId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const attemptId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; diff --git a/apps/web/src/lib/security-agent/router/shared-handlers.ts b/apps/web/src/lib/security-agent/router/shared-handlers.ts index 890bb36505..7c93cb5fe2 100644 --- a/apps/web/src/lib/security-agent/router/shared-handlers.ts +++ b/apps/web/src/lib/security-agent/router/shared-handlers.ts @@ -60,7 +60,7 @@ import { countEligibleForAutoDismiss, } from '@/lib/security-agent/services/auto-dismiss-service'; import type { SecurityReviewOwner } from '@/lib/security-agent/core/types'; -import { organizations, type SecurityFinding } from '@kilocode/db/schema'; +import { organizations, type OperationLedgerRow, type SecurityFinding } from '@kilocode/db/schema'; import { buildSecurityFindingAuditHumanActor } from '@kilocode/worker-utils/security-finding-audit'; import { db } from '@/lib/drizzle'; import { eq } from 'drizzle-orm'; @@ -115,6 +115,14 @@ import { logSecurityAudit, SecurityAuditLogAction, } from '@/lib/security-agent/services/audit-log-service'; +import { + admitOperation, + markReconcilePending, + recordOperationProgress, + setOperationProviderRef, + settleOperation, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; // --------------------------------------------------------------------------- // Strategy types @@ -246,6 +254,314 @@ async function assembleAuditReportResponse(params: { } } +// --------------------------------------------------------------------------- +// Security operation ledger (P1-A-08e) +// --------------------------------------------------------------------------- +// +// The manual sync and finding dismissal procedures accept an optional +// `operationKey`. When present, the handler admits a `security`-domain ledger +// row BEFORE submitting to the security-sync Worker, and only then runs the +// submission. Later same-key calls dedupe, replay the canonical result, or +// conflict, and the Worker joins the terminal outcome by the stored +// `provider_ref` (the Worker's `messageId`). A definitive pre-acceptance +// rejection (4xx, disabled routing, unconfigured service) settles the row +// `failed`; an ambiguous transport outcome (network/5xx/lost correlation ids) +// marks the row `reconcile_pending` so a same-key retry re-submits instead of +// re-executing blind. + +const SECURITY_LEDGER_DOMAIN = 'security' as const; +/** The in-flight window: while an `admitted` row holds a live lease, same-key + * retries receive CONFLICT `operation_in_progress` instead of re-submitting. */ +const SECURITY_LEDGER_LEASE_SECONDS = 120; + +const SECURITY_LEDGER_INTENTS = ['manual_sync', 'dismiss_finding'] as const; +type SecurityLedgerIntent = (typeof SECURITY_LEDGER_INTENTS)[number]; + +// Client-facing CONFLICT markers (stable values the mobile hooks map onto the +// existing per-surface retryable copy). `operation_key_reuse_mismatch` is the +// cross-intent rejection: a caller that reuses an existing key for a DIFFERENT +// intent/resource/request is refused without any effect or replay. +const OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const SECURITY_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the security review before retrying."; +const SECURITY_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const SECURITY_OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +// The Worker accepted the command but the ledger provider reference could not +// be recorded: a success receipt would falsely claim retry safety. Surface a +// retryable server error so a same-key retry re-submits and re-records. +const SECURITY_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; +// An ambiguous outcome whose `reconcile_pending` persistence failed: the +// reconcile-pending guarantee (same-key retries dedupe/reconcile) does not +// hold, so a distinct non-retryable persistence error is surfaced instead. +const SECURITY_LEDGER_PERSISTENCE_FAILED_MESSAGE = + 'We could not record this action. Please try again later.'; + +function operationInProgressError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: OPERATION_IN_PROGRESS_MESSAGE }); +} + +function ambiguousSecurityError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: SECURITY_AMBIGUOUS_MESSAGE }); +} + +function operationKeyReuseMismatchError(): TRPCError { + return new TRPCError({ + code: 'CONFLICT', + message: SECURITY_OPERATION_KEY_REUSE_MISMATCH_MESSAGE, + }); +} + +/** `security_command_settled` outbox payload (DEC-05): no free text, no resource keys. */ +function securitySettledOutboxEvent(params: { + distinctId: string; + intent: SecurityLedgerIntent; + outcome: 'completed' | 'failed' | 'ambiguous'; + startedAt: number; +}): OutboxEventInput { + return { + eventName: 'security_command_settled', + distinctId: params.distinctId, + properties: { + source: 'web', + surface: 'security', + phase: 'terminal', + intent: params.intent, + outcome: params.outcome, + duration_ms: Math.max(0, Date.now() - params.startedAt), + }, + }; +} + +/** True when the submission outcome is ambiguous transport (never settled). */ +function isAmbiguousSecuritySubmitError(error: unknown): boolean { + return error instanceof TRPCError && error.code === 'BAD_GATEWAY'; +} + +function securityOwnerScopeKey(owner: SecurityReviewOwner): string { + return 'organizationId' in owner && owner.organizationId + ? `org:${owner.organizationId}` + : `user:${owner.userId}`; +} + +/** Security ledger resource identity for a manual sync (owner scope + repo). */ +export function securitySyncLedgerResourceKey( + owner: SecurityReviewOwner, + repoFullName?: string +): string { + return `security:manual_sync:${securityOwnerScopeKey(owner)}:${repoFullName ?? '*'}`; +} + +/** Security ledger resource identity for a finding dismissal. */ +export function securityDismissLedgerResourceKey( + owner: SecurityReviewOwner, + findingId: string +): string { + return `security:dismiss_finding:${securityOwnerScopeKey(owner)}:${findingId}`; +} + +/** + * Best-effort ledger write, reserved for FAILED-status settles only: the + * caller is already receiving a typed rejection, so a ledger write that fails + * here must never mask the worker outcome — the error is being surfaced + * regardless and a later same-key retry re-records it. + */ +async function bestEffortLedgerWrite(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + console.error( + `Failed to write security operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Durably records the Worker's accepted correlation ids on the ledger row: the + * `provider_ref` (Worker `messageId`) the Worker later joins on, plus the + * replay-safe `commandId`/`runId`/`messageId` in `canonical_result`. A failure + * must never yield a success receipt for an un-recorded row: the caller + * surfaces a retryable server error and a same-key retry re-submits. + */ +async function recordSecurityAcceptance( + row: OperationLedgerRow, + accepted: { commandId: string; runId: string; messageId: string } +): Promise { + try { + await setOperationProviderRef(db, { rowId: row.id, providerRef: accepted.messageId }); + await recordOperationProgress(db, row.id, { + commandId: accepted.commandId, + runId: accepted.runId, + messageId: accepted.messageId, + }); + } catch (error) { + console.error( + `Failed to record security operation acceptance: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: SECURITY_LEDGER_SETTLE_FAILED_MESSAGE, + cause: error, + }); + } +} + +/** + * Durably marks a security ledger row `reconcile_pending` with the + * deterministic ambiguous outbox event. The ambiguous CONFLICT is surfaced + * ONLY after this succeeds: otherwise the reconcile-pending guarantee does not + * hold and a distinct non-retryable persistence error is thrown instead. + */ +async function markSecurityRowReconcilePending(args: { + row: OperationLedgerRow; + intent: SecurityLedgerIntent; + distinctId: string; + startedAt: number; +}): Promise { + try { + await markReconcilePending(db, { + rowId: args.row.id, + outboxEvent: securitySettledOutboxEvent({ + distinctId: args.distinctId, + intent: args.intent, + outcome: 'ambiguous', + startedAt: args.startedAt, + }), + }); + } catch (error) { + console.error( + `Failed to mark security operation ledger row reconcile-pending: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: SECURITY_LEDGER_PERSISTENCE_FAILED_MESSAGE, + cause: error, + }); + } +} + +/** Replays a terminal row: only `completed`/`no_op` may replay a canonical result. */ +function replaySettledSecurityRow( + row: OperationLedgerRow +): { replayed: true } & Record { + if (row.status === 'completed' || row.status === 'no_op') { + return { ...(row.canonical_result ?? {}), replayed: true } as { replayed: true } & Record< + string, + unknown + >; + } + // A settled `failed` row cannot be recovered under the same key: surface a + // non-retryable typed rejection so the client starts a fresh intent. + throw new TRPCError({ code: 'BAD_REQUEST', message: SECURITY_REPLAY_FAILED_MESSAGE }); +} + +/** + * Runs the Worker submission under an already-admitted row: + * - acceptance records the provider reference durably, then returns accepted; + * - a definitive pre-acceptance rejection settles the row `failed`; + * - an ambiguous transport outcome marks the row `reconcile_pending`. + */ +async function executeSecurityCommandSubmit(args: { + row: OperationLedgerRow; + intent: SecurityLedgerIntent; + distinctId: string; + startedAt: number; + submit: () => Promise<{ commandId: string; runId: string; messageId: string }>; +}): Promise<{ commandId: string; runId: string; messageId: string }> { + let accepted: { commandId: string; runId: string; messageId: string }; + try { + accepted = await args.submit(); + } catch (error) { + if (isAmbiguousSecuritySubmitError(error)) { + await markSecurityRowReconcilePending({ + row: args.row, + intent: args.intent, + distinctId: args.distinctId, + startedAt: args.startedAt, + }); + throw ambiguousSecurityError(); + } + await bestEffortLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: 'pre_acceptance_rejected', + outboxEvent: securitySettledOutboxEvent({ + distinctId: args.distinctId, + intent: args.intent, + outcome: 'failed', + startedAt: args.startedAt, + }), + }) + ); + throw error; + } + // A failure to record the acceptance must never settle the row: it surfaces + // a retryable server error and a same-key retry re-submits (and re-records). + await recordSecurityAcceptance(args.row, accepted); + return accepted; +} + +/** + * Admits a `security`-domain ledger row and routes the admission: + * `admitted` runs the Worker submission; `takeover`/`duplicate_reconcile_pending` + * re-submit (the Worker owns the sync state, so reconciliation is a re-enqueue + * under the same key); `duplicate_settled` replays the canonical result; + * in-flight admissions conflict. Cross-intent key reuse is rejected before any + * outcome is honored. + */ +async function runSecurityLedgerSubmit(args: { + ctx: TRPCContext; + owner: SecurityReviewOwner; + intent: SecurityLedgerIntent; + operationKey: string; + resourceKey: string; + submit: () => Promise<{ commandId: string; runId: string; messageId: string }>; +}): Promise< + | { kind: 'accepted'; accepted: { commandId: string; runId: string; messageId: string } } + | { kind: 'replayed'; canonical: { replayed: true } & Record } +> { + const distinctId = args.ctx.user.google_user_email || args.ctx.user.id; + const startedAt = Date.now(); + const admission = await admitOperation(db, { + userId: args.ctx.user.id, + orgId: + 'organizationId' in args.owner && args.owner.organizationId + ? args.owner.organizationId + : null, + domain: SECURITY_LEDGER_DOMAIN, + intent: args.intent, + operationKey: args.operationKey, + resourceKey: args.resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: SECURITY_LEDGER_LEASE_SECONDS, + }); + + if (admission.row.intent !== args.intent || admission.row.resource_key !== args.resourceKey) { + throw operationKeyReuseMismatchError(); + } + + switch (admission.admission) { + case 'admitted': + case 'takeover': + case 'duplicate_reconcile_pending': + return { + kind: 'accepted', + accepted: await executeSecurityCommandSubmit({ + row: admission.row, + intent: args.intent, + distinctId, + startedAt, + submit: args.submit, + }), + }; + case 'duplicate_settled': + return { kind: 'replayed', canonical: replaySettledSecurityRow(admission.row) }; + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw operationInProgressError(); + } +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -1055,7 +1371,10 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps const allRepos = requireNumericPlatformRepositories(integration.repositories) ?? []; - // If a specific repo is provided, sync only that one + // Resolve the sync scope (a specific repo, or every enabled repo). + let syncScope: + | { syncType: 'single_repo'; repoCount: number; repoFullName: string } + | { syncType: 'all_repos'; repoCount: number }; if (input.repoFullName) { const hasRepo = allRepos.some(r => r.full_name === input.repoFullName); if (!hasRepo) { @@ -1064,28 +1383,82 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps message: 'Repository not found in your GitHub integration', }); } + syncScope = { + syncType: 'single_repo', + repoCount: 1, + repoFullName: input.repoFullName, + }; + } else { + const config = await getSecurityAgentConfigWithStatus(owner); + const selectionMode = config?.config.repository_selection_mode ?? 'selected'; + const selectedIds = config?.config.selected_repository_ids ?? []; + + const repositoriesToSync: string[] = + selectionMode === 'all' + ? allRepos.map(r => r.full_name).filter((name): name is string => !!name) + : allRepos + .filter(r => selectedIds.includes(r.id)) + .map(r => r.full_name) + .filter((name): name is string => !!name); + + if (repositoriesToSync.length === 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'No repositories configured for security reviews', + }); + } + syncScope = { syncType: 'all_repos', repoCount: repositoriesToSync.length }; + } + + const submitParams = { + owner: securityOwner, + actor: { + id: ctx.user.id, + email: ctx.user.google_user_email, + name: ctx.user.google_user_name, + }, + origin: 'dashboard_refresh' as const, + repoFullName: input.repoFullName, + }; - const accepted = await submitManualSecuritySync({ + // With an `operationKey`, admit a `security`-domain ledger row before + // submitting (P1-A-08e). A replayed settled duplicate returns the + // recorded correlation ids without re-triggering tracking or audit. + if (input.operationKey !== undefined) { + const result = await runSecurityLedgerSubmit({ + ctx, owner: securityOwner, - actor: { - id: ctx.user.id, - email: ctx.user.google_user_email, - name: ctx.user.google_user_name, - }, - origin: 'dashboard_refresh', - repoFullName: input.repoFullName, + intent: 'manual_sync', + operationKey: input.operationKey, + resourceKey: securitySyncLedgerResourceKey(securityOwner, input.repoFullName), + submit: () => submitManualSecuritySync(submitParams), }); - + if (result.kind === 'replayed') { + return { + success: true, + accepted: true, + commandId: + typeof result.canonical.commandId === 'string' + ? result.canonical.commandId + : undefined, + runId: + typeof result.canonical.runId === 'string' ? result.canonical.runId : undefined, + messageId: + typeof result.canonical.messageId === 'string' + ? result.canonical.messageId + : undefined, + replayed: true, + }; + } trackSecurityAgentSync({ distinctId: ctx.user.id, userId: ctx.user.id, ...deps.trackingExtras(ctx, input), - syncType: 'single_repo', - repoCount: 1, + syncType: syncScope.syncType, + repoCount: syncScope.repoCount, synced: 0, errors: 0, }); - logSecurityAudit({ owner: securityOwner, actor_id: ctx.user.id, @@ -1095,60 +1468,28 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps resource_type: 'agent_config', resource_id: resourceId, metadata: { - syncType: 'single_repo', - repoFullName: input.repoFullName, - runId: accepted.runId, - messageId: accepted.messageId, + syncType: syncScope.syncType, + ...(input.repoFullName ? { repoFullName: input.repoFullName } : {}), + repoCount: syncScope.repoCount, + runId: result.accepted.runId, + messageId: result.accepted.messageId, status: 'accepted', }, }); - return { success: true, - ...accepted, + ...result.accepted, }; } - // No specific repo - sync all enabled repositories based on config - const config = await getSecurityAgentConfigWithStatus(owner); - const selectionMode = config?.config.repository_selection_mode ?? 'selected'; - const selectedIds = config?.config.selected_repository_ids ?? []; - - let repositoriesToSync: string[]; - if (selectionMode === 'all') { - repositoriesToSync = allRepos - .map(r => r.full_name) - .filter((name): name is string => !!name); - } else { - repositoriesToSync = allRepos - .filter(r => selectedIds.includes(r.id)) - .map(r => r.full_name) - .filter((name): name is string => !!name); - } - - if (repositoriesToSync.length === 0) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'No repositories configured for security reviews', - }); - } - - const accepted = await submitManualSecuritySync({ - owner: securityOwner, - actor: { - id: ctx.user.id, - email: ctx.user.google_user_email, - name: ctx.user.google_user_name, - }, - origin: 'dashboard_refresh', - }); + const accepted = await submitManualSecuritySync(submitParams); trackSecurityAgentSync({ distinctId: ctx.user.id, userId: ctx.user.id, ...deps.trackingExtras(ctx, input), - syncType: 'all_repos', - repoCount: repositoriesToSync.length, + syncType: syncScope.syncType, + repoCount: syncScope.repoCount, synced: 0, errors: 0, }); @@ -1162,8 +1503,9 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps resource_type: 'agent_config', resource_id: resourceId, metadata: { - syncType: 'all_repos', - repoCount: repositoriesToSync.length, + syncType: syncScope.syncType, + ...(input.repoFullName ? { repoFullName: input.repoFullName } : {}), + repoCount: syncScope.repoCount, runId: accepted.runId, messageId: accepted.messageId, status: 'accepted', @@ -1226,14 +1568,57 @@ export function createSecurityAgentHandlers(deps: SecurityAgentDeps }); } - const accepted = await submitManualFindingDismissal({ + const submitParams = { owner: securityOwner, actor: { id: ctx.user.id }, findingId: input.findingId, installationId, reason: input.reason, comment: input.comment, - }); + }; + + // With an `operationKey`, admit a `security`-domain ledger row before + // submitting (P1-A-08e). A replayed settled duplicate returns the + // recorded correlation ids without re-triggering tracking. + if (input.operationKey !== undefined) { + const result = await runSecurityLedgerSubmit({ + ctx, + owner: securityOwner, + intent: 'dismiss_finding', + operationKey: input.operationKey, + resourceKey: securityDismissLedgerResourceKey(securityOwner, input.findingId), + submit: () => submitManualFindingDismissal(submitParams), + }); + if (result.kind === 'replayed') { + return { + success: true, + accepted: true, + commandId: + typeof result.canonical.commandId === 'string' + ? result.canonical.commandId + : undefined, + runId: + typeof result.canonical.runId === 'string' ? result.canonical.runId : undefined, + messageId: + typeof result.canonical.messageId === 'string' + ? result.canonical.messageId + : undefined, + replayed: true, + }; + } + trackSecurityAgentFindingDismissed({ + distinctId: ctx.user.id, + userId: ctx.user.id, + ...deps.trackingExtras(ctx, input), + findingId: input.findingId, + reason: input.reason, + source: finding.source, + severity: finding.severity, + }); + return { success: true, ...result.accepted }; + } + + const accepted = await submitManualFindingDismissal(submitParams); trackSecurityAgentFindingDismissed({ distinctId: ctx.user.id, diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts index 38c2f2e449..fe5e9c0489 100644 --- a/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts @@ -1,3 +1,4 @@ +import { TRPCError } from '@trpc/server'; import { submitManualFindingDismissal } from './manual-dismiss-client'; jest.mock('@/lib/config.server', () => ({ @@ -59,4 +60,158 @@ describe('submitManualFindingDismissal', () => { }); expect(JSON.parse(String(request.body)).actor).toEqual({ id: 'user-123' }); }); + + it('throws a TRPCError (not a raw Error) when fetch rejects with a transport error', async () => { + mockFetch.mockRejectedValue(new Error('network down')); + + await expect( + submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }) + ).rejects.toMatchObject({ + name: 'TRPCError', + code: 'BAD_GATEWAY', + }); + }); + + it('classifies a 5xx status as ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + json: () => Promise.resolve({ error: 'boom' }), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + expect((captured as TRPCError).message).toContain('502'); + expect((captured as TRPCError).message).not.toContain('boom'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); + + it('classifies a 4xx status as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: 'invalid' }), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('PRECONDITION_FAILED'); + expect((captured as TRPCError).message).not.toContain('invalid'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); + + it('classifies a non-JSON body as ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + }); + + it('classifies a 2xx with lost correlation ids as ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 202, + json: () => Promise.resolve({ success: true, accepted: true }), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); +}); + +describe('submitManualFindingDismissal env configuration', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('throws a TRPCError when SECURITY_SYNC_WORKER_URL is empty (not a raw Error)', async () => { + jest.resetModules(); + jest.doMock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal-secret', + SECURITY_SYNC_WORKER_URL: '', + })); + const mod = await import('./manual-dismiss-client'); + let captured: unknown; + try { + await mod.submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + } catch (e) { + captured = e; + } + expect(captured).toBeDefined(); + expect((captured as { name?: string }).name).toBe('TRPCError'); + expect((captured as { code?: string }).code).toBe('INTERNAL_SERVER_ERROR'); + expect((captured as Error).message).toContain('not configured'); + expect((captured as Error).message).not.toContain('test-internal-secret'); + }); }); diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts index e869306e96..96a9c5cf72 100644 --- a/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts @@ -1,4 +1,5 @@ import 'server-only'; +import { TRPCError } from '@trpc/server'; import { INTERNAL_API_SECRET, SECURITY_SYNC_WORKER_URL } from '@/lib/config.server'; type ManualFindingDismissalOwner = @@ -40,45 +41,93 @@ export async function submitManualFindingDismissal( params: SubmitManualFindingDismissalParams ): Promise { if (!SECURITY_SYNC_WORKER_URL) { - throw new Error('SECURITY_SYNC_WORKER_URL is not configured'); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Security dismissal service is not configured', + }); } if (!INTERNAL_API_SECRET) { - throw new Error('INTERNAL_API_SECRET is not configured'); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Security dismissal service is not configured', + }); } - const response = await fetch(`${SECURITY_SYNC_WORKER_URL}/internal/dismiss-finding`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-internal-api-key': INTERNAL_API_SECRET, - }, - body: JSON.stringify({ - schemaVersion: 1, - owner: params.owner, - actor: params.actor, - findingId: params.findingId, - installationId: params.installationId, - reason: params.reason, - comment: params.comment, - }), - }); - const body = (await response.json()) as ManualFindingDismissalWorkerResponse; + let response: Response; + let body: ManualFindingDismissalWorkerResponse | undefined; + try { + response = await fetch(`${SECURITY_SYNC_WORKER_URL}/internal/dismiss-finding`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify({ + schemaVersion: 1, + owner: params.owner, + actor: params.actor, + findingId: params.findingId, + installationId: params.installationId, + reason: params.reason, + comment: params.comment, + }), + }); + try { + body = (await response.json()) as ManualFindingDismissalWorkerResponse; + } catch { + // Non-JSON response body (e.g. gateway HTML/error page). The Worker may + // still have accepted and enqueued the command, so this is ambiguous + // transport — never a definitive rejection. + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Could not reach the security dismissal service. Try again.', + }); + } + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + // A network failure is ambiguous transport: the command may have been + // accepted before the connection dropped. + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Could not reach the security dismissal service. Try again.', + }); + } if (!response.ok) { - throw new Error( - body.error ?? `Security dismissal Worker request failed with ${response.status}` - ); + // A 5xx is ambiguous transport: the Worker may or may not have accepted. + if (response.status >= 500) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: `Security dismissal service request failed (status ${response.status}). Try again.`, + }); + } + // A 4xx is a definitive pre-acceptance rejection. Do not blindly + // interpolate body.error — the worker may not be ours and the body can be + // attacker/gateway-controlled HTML. Keep the message short and non-secret. + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Security dismissal service request failed (status ${response.status}).`, + }); } if ( + !body || body.success !== true || body.accepted !== true || typeof body.commandId !== 'string' || typeof body.runId !== 'string' || typeof body.messageId !== 'string' ) { - throw new Error('Security dismissal Worker returned an invalid accepted response'); + // A 2xx with an invalid accepted shape: the Worker accepted the command + // but the correlation ids were lost, so the provider reference cannot be + // recorded. Ambiguous transport. + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: 'Security dismissal service returned an unexpected response. Try again.', + }); } return { diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts index b88081069a..7c3d4377f7 100644 --- a/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts @@ -70,7 +70,7 @@ describe('submitManualSecuritySync', () => { }) ).rejects.toMatchObject({ name: 'TRPCError', - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_GATEWAY', }); try { @@ -110,7 +110,50 @@ describe('submitManualSecuritySync', () => { captured = e; } expect(captured).toBeInstanceOf(TRPCError); - expect((captured as TRPCError).code).toBe('INTERNAL_SERVER_ERROR'); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); + + it('classifies a 5xx status as ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + json: () => Promise.resolve({ error: 'boom' }), + }); + + await expect( + submitManualSecuritySync({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + }) + ).rejects.toMatchObject({ + name: 'TRPCError', + code: 'BAD_GATEWAY', + }); + }); + + it('classifies a 4xx status as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: 'invalid request' }), + }); + + let captured: unknown; + try { + await submitManualSecuritySync({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('PRECONDITION_FAILED'); + expect((captured as TRPCError).message).toContain('400'); + expect((captured as TRPCError).message).not.toContain('invalid request'); expect((captured as TRPCError).message).not.toContain('security-sync.test'); expect((captured as TRPCError).message).not.toContain('test-internal-secret'); }); @@ -133,7 +176,7 @@ describe('submitManualSecuritySync', () => { captured = e; } expect(captured).toBeInstanceOf(TRPCError); - expect((captured as TRPCError).code).toBe('INTERNAL_SERVER_ERROR'); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); // Generic status-bearing message; must not echo the worker error or our secret expect((captured as TRPCError).message).toContain('500'); expect((captured as TRPCError).message).not.toContain('boom'); @@ -159,7 +202,7 @@ describe('submitManualSecuritySync', () => { captured = e; } expect(captured).toBeInstanceOf(TRPCError); - expect((captured as TRPCError).code).toBe('INTERNAL_SERVER_ERROR'); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); expect((captured as TRPCError).message).not.toContain('security-sync.test'); expect((captured as TRPCError).message).not.toContain('test-internal-secret'); }); diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.ts index a277c779df..fa7c8dde00 100644 --- a/apps/web/src/lib/security-agent/services/manual-sync-client.ts +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.ts @@ -72,9 +72,11 @@ export async function submitManualSecuritySync( try { body = (await response.json()) as ManualSecuritySyncWorkerResponse; } catch { - // Non-JSON response body (e.g. gateway HTML/error page) — treat as transport failure + // Non-JSON response body (e.g. gateway HTML/error page). The Worker may + // still have accepted and enqueued the command, so this is ambiguous + // transport — never a definitive rejection. throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_GATEWAY', message: 'Could not reach the security sync service. Try again.', }); } @@ -82,18 +84,29 @@ export async function submitManualSecuritySync( if (error instanceof TRPCError) { throw error; } + // A network failure is ambiguous transport: the command may have been + // accepted before the connection dropped. throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_GATEWAY', message: 'Could not reach the security sync service. Try again.', }); } if (!response.ok) { - // Do not blindly interpolate body.error — the worker may not be ours and the body - // can be attacker/gateway-controlled HTML. Keep the message short and non-secret. + // A 5xx is ambiguous transport: the Worker may or may not have accepted. + if (response.status >= 500) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: `Security sync service request failed (status ${response.status}). Try again.`, + }); + } + // A 4xx is a definitive pre-acceptance rejection (validation, auth, + // disabled routing). Do not blindly interpolate body.error — the worker + // may not be ours and the body can be attacker/gateway-controlled HTML. + // Keep the message short and non-secret. throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Security sync service request failed (status ${response.status}). Try again.`, + code: 'PRECONDITION_FAILED', + message: `Security sync service request failed (status ${response.status}).`, }); } @@ -105,8 +118,11 @@ export async function submitManualSecuritySync( typeof body.runId !== 'string' || typeof body.messageId !== 'string' ) { + // A 2xx with an invalid accepted shape: the Worker accepted the command + // but the correlation ids were lost, so the provider reference cannot be + // recorded. Ambiguous transport. throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', + code: 'BAD_GATEWAY', message: 'Security sync service returned an unexpected response. Try again.', }); } diff --git a/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts b/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts new file mode 100644 index 0000000000..622bc7b0d7 --- /dev/null +++ b/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts @@ -0,0 +1,568 @@ +/** + * @jest-environment node + */ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { createCallerFactory } from '@/lib/trpc/init'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; +import type * as organizationsModule from '@/lib/organizations/organizations'; +import type * as auditLogModule from '@/lib/organizations/organization-audit-logs'; +import type * as userModule from '@/lib/user'; +import type * as lifecycleServiceModule from '@/lib/mcp-gateway/lifecycle-service'; +import type * as instanceRegistryModule from '@/lib/kiloclaw/instance-registry'; + +// P1-A-08e: the organization operation ledger. The role-change and member +// removal mutations admit / settle through `@kilocode/db/operation-ledger`, +// read memberships back through `@/lib/drizzle`, and write the success audit +// + settle + outbox inside one transaction. All are mocked so the ledger tests +// assert admission, replay, failed-helper settlement, atomicity, and read-back +// takeover orchestration without a database. +const mockAdmitOperation = jest.fn<(...args: unknown[]) => Promise>(); +const mockSettleOperation = jest.fn<(...args: unknown[]) => Promise>(); + +jest.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: (...args: unknown[]) => mockAdmitOperation(...args), + settleOperation: (...args: unknown[]) => mockSettleOperation(...args), +})); + +const mockUpdateUserRoleInOrganization = jest.fn() as jest.MockedFunction< + typeof organizationsModule.updateUserRoleInOrganization +>; +const mockRemoveUserFromOrganization = jest.fn() as jest.MockedFunction< + typeof organizationsModule.removeUserFromOrganization +>; +const mockCreateAuditLog = jest.fn() as jest.MockedFunction; +const mockFindUserById = jest.fn() as jest.MockedFunction; +const mockUpdateOrganizationUserLimit = jest.fn(); +const mockRevokeGatewayStateForOrganizationMember = jest.fn() as jest.MockedFunction< + typeof lifecycleServiceModule.revokeGatewayStateForOrganizationMember +>; +const mockDestroyOrgInstancesForUser = jest.fn() as jest.MockedFunction< + typeof instanceRegistryModule.destroyOrgInstancesForUser +>; + +jest.mock('@/lib/organizations/organizations', () => ({ + updateUserRoleInOrganization: mockUpdateUserRoleInOrganization, + removeUserFromOrganization: mockRemoveUserFromOrganization, + getOrganizationById: jest.fn(), + getOrganizationMembers: jest.fn(), + addUserToOrganization: jest.fn(), + inviteUserToOrganization: jest.fn(), + getAcceptInviteUrl: jest.fn(), +})); +jest.mock('@/lib/organizations/organization-usage', () => ({ + updateOrganizationUserLimit: mockUpdateOrganizationUserLimit, +})); +jest.mock('@/lib/organizations/organization-audit-logs', () => ({ + createAuditLog: mockCreateAuditLog, +})); +jest.mock('@/lib/user', () => ({ findUserById: mockFindUserById })); +jest.mock('@/lib/organizations/trial-middleware', () => ({ + requireActiveSubscriptionOrTrial: jest + .fn<(organizationId: string) => Promise<{ isReadOnly: boolean; daysRemaining: number }>>() + .mockResolvedValue({ isReadOnly: false, daysRemaining: Infinity }), +})); +jest.mock('@/lib/mcp-gateway/lifecycle-service', () => ({ + revokeGatewayStateForOrganizationMember: mockRevokeGatewayStateForOrganizationMember, +})); +jest.mock('@/lib/kiloclaw/instance-registry', () => ({ + destroyOrgInstancesForUser: mockDestroyOrgInstancesForUser, +})); +jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => ({ + KiloClawInternalClient: jest.fn().mockImplementation(() => ({ destroy: jest.fn() })), +})); + +// The router reads memberships back through `db` from `@/lib/drizzle`. The mock +// is static (the router is imported once) but its per-query results are driven +// by a mutable state object that each test configures. +const mockDbState = { + targetMember: [] as unknown[], + roleReadBack: [] as unknown[], + removeTargetMember: [] as unknown[], + memberReadBack: [] as unknown[], +}; +const tx = { __tx: true }; +const mockDb = { + select: jest.fn<() => unknown>(), + transaction: jest.fn<(callback: (tx: unknown) => unknown) => unknown>(), +}; + +jest.mock('@/lib/drizzle', () => ({ db: mockDb })); + +let createCaller: any; +let caller: any; + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const MEMBER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +const ownerUser = { + id: 'owner-user-1', + google_user_email: 'owner@example.com', + google_user_name: 'Owner Example', + is_admin: true, +} as never; + +beforeAll(async () => { + const mod = await import('./organization-members-router'); + createCaller = createCallerFactory(mod.organizationsMembersRouter); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockDbState.targetMember = []; + mockDbState.roleReadBack = []; + mockDbState.removeTargetMember = []; + mockDbState.memberReadBack = []; + mockDb.select.mockImplementation(() => ({ + from: () => ({ + where: () => { + const query = { + limit: async () => + mockDbState.roleReadBack.length > 0 + ? mockDbState.roleReadBack + : mockDbState.memberReadBack, + then: (resolve: (value: unknown) => void) => + resolve( + mockDbState.targetMember.length > 0 + ? mockDbState.targetMember + : mockDbState.removeTargetMember + ), + }; + return query; + }, + innerJoin: () => ({ + where: () => ({ + then: (resolve: (value: unknown) => void) => resolve(mockDbState.removeTargetMember), + }), + }), + }), + })); + mockDb.transaction.mockImplementation(async (callback: (value: unknown) => unknown) => + callback(tx) + ); + mockSettleOperation.mockResolvedValue({ settled: true }); + mockUpdateUserRoleInOrganization.mockResolvedValue({ success: true, updated: 'membership' }); + mockRemoveUserFromOrganization.mockResolvedValue({ rowCount: 1 }); + mockFindUserById.mockResolvedValue({ google_user_email: 'member@example.com' } as never); + mockDestroyOrgInstancesForUser.mockResolvedValue([] as never); + mockRevokeGatewayStateForOrganizationMember.mockResolvedValue(undefined); + caller = createCaller({ user: ownerUser }); +}); + +function ledgerRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: 'org-ledger-row-id', + operation_key: 'org-op-key-1', + domain: 'organization', + intent: 'member_role_change', + kilo_user_id: 'owner-user-1', + organization_id: ORG_ID, + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}:role:member`, + provider_ref: null, + taxonomy: 'reconcile-first', + status: 'admitted', + outcome_code: null, + canonical_result: null, + admitted_at: '2026-06-17T10:00:00.000Z', + settled_at: null, + lease_expires_at: '2026-06-17T10:02:00.000Z', + expires_at: '2026-07-17T10:00:00.000Z', + ...overrides, + }; +} + +describe('organizations members ledger (P1-A-08e)', () => { + describe('update: role-change ledger', () => { + const input = { + organizationId: ORG_ID, + memberId: MEMBER_ID, + role: 'member' as const, + operationKey: 'org-op-key-1', + }; + + it('admits before the helper and settles completed with audit + outbox in one transaction', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockDbState.targetMember = [{ role: 'owner' }]; + + const result = await caller.update(input); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + userId: 'owner-user-1', + orgId: ORG_ID, + domain: 'organization', + intent: 'member_role_change', + operationKey: 'org-op-key-1', + resourceKey: `organization:${ORG_ID}:member:${MEMBER_ID}:role:member`, + taxonomy: 'reconcile-first', + leaseSeconds: 120, + }) + ); + expect(mockUpdateUserRoleInOrganization).toHaveBeenCalledWith(ORG_ID, MEMBER_ID, 'member'); + + // The success audit log and the terminal settle share one transaction. + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'organization.member.change_role', + organization_id: ORG_ID, + tx, + }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { updated: 'role and limit' }, + }) + ); + const settleCall = mockSettleOperation.mock.calls[0]?.[1] as { + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleCall?.outboxEvent).toMatchObject({ + eventName: 'organization_write_settled', + distinctId: 'owner@example.com', + properties: { + source: 'web', + surface: 'organization', + phase: 'terminal', + intent: 'member_role_change', + outcome: 'completed', + }, + }); + }); + + it('settles the row failed without success audit or outbox when the helper fails', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'admitted', row: ledgerRow() }); + mockUpdateUserRoleInOrganization.mockResolvedValue({ success: false, updated: 'none' }); + mockDbState.targetMember = [{ role: 'owner' }]; + + await expect(caller.update(input)).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Failed to update user role', + }); + + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'failed', + outcomeCode: 'role_change_failed', + }) + ); + const settleCall = mockSettleOperation.mock.calls[0]?.[1] as { + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleCall?.outboxEvent).toMatchObject({ + eventName: 'organization_write_settled', + properties: { intent: 'member_role_change', outcome: 'failed' }, + }); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + expect(mockDb.transaction).not.toHaveBeenCalled(); + }); + + it('replays a settled duplicate without re-running the helper or re-settling', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_settled', + row: ledgerRow({ + status: 'completed', + canonical_result: { updated: 'role and limit' }, + }), + }); + mockDbState.targetMember = [{ role: 'owner' }]; + + const result = await caller.update(input); + + expect(result).toEqual({ success: true, updated: 'role and limit', replayed: true }); + expect(mockUpdateUserRoleInOrganization).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + }); + + it('conflicts on an in-flight duplicate instead of re-running the helper', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_in_flight', + row: ledgerRow(), + }); + mockDbState.targetMember = [{ role: 'owner' }]; + + await expect(caller.update(input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_in_progress', + }); + expect(mockUpdateUserRoleInOrganization).not.toHaveBeenCalled(); + }); + + it('rejects cross-intent key reuse before honoring any outcome', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ intent: 'member_remove' }), + }); + mockDbState.targetMember = [{ role: 'owner' }]; + + await expect(caller.update(input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_key_reuse_mismatch', + }); + expect(mockUpdateUserRoleInOrganization).not.toHaveBeenCalled(); + }); + }); + + describe('update: read-back takeover repair for role change', () => { + const input = { + organizationId: ORG_ID, + memberId: MEMBER_ID, + role: 'member' as const, + operationKey: 'org-op-key-1', + }; + + it('settles completed and replays when the read-back already shows the target role', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'takeover', row: ledgerRow() }); + mockDbState.targetMember = [{ role: 'member' }]; + mockDbState.roleReadBack = [{ role: 'member' }]; + + const result = await caller.update(input); + + expect(result).toEqual({ success: true, updated: 'role and limit', replayed: true }); + expect(mockUpdateUserRoleInOrganization).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.member.change_role', tx }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ rowId: 'org-ledger-row-id', status: 'completed' }) + ); + }); + + it('settles the row failed when the read-back shows the member is gone', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'takeover', row: ledgerRow() }); + mockDbState.targetMember = [{ role: 'member' }]; + mockDbState.roleReadBack = []; + + await expect(caller.update(input)).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'User is not a member of this organization', + }); + + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'failed', + outcomeCode: 'member_absent', + }) + ); + expect(mockUpdateUserRoleInOrganization).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + }); + + it('re-runs the helper under the same row when the read-back shows a different role', async () => { + mockAdmitOperation.mockResolvedValue({ admission: 'takeover', row: ledgerRow() }); + mockDbState.targetMember = [{ role: 'owner' }]; + mockDbState.roleReadBack = [{ role: 'owner' }]; + + const result = await caller.update(input); + + expect(mockUpdateUserRoleInOrganization).toHaveBeenCalledWith(ORG_ID, MEMBER_ID, 'member'); + expect(result).toEqual({ success: true, updated: 'role and limit' }); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ rowId: 'org-ledger-row-id', status: 'completed' }) + ); + }); + }); + + describe('remove: member-removal ledger', () => { + const input = { + organizationId: ORG_ID, + memberId: MEMBER_ID, + operationKey: 'org-op-key-1', + }; + + it('admits before the helper and settles completed with audit + outbox in one transaction', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + + const result = await caller.remove(input); + + expect(result).toEqual({ success: true, updated: MEMBER_ID }); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + domain: 'organization', + intent: 'member_remove', + resourceKey: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }) + ); + expect(mockRemoveUserFromOrganization).toHaveBeenCalledWith( + ORG_ID, + MEMBER_ID, + 'owner-user-1' + ); + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'organization.member.remove', + organization_id: ORG_ID, + tx, + }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { updated: MEMBER_ID }, + }) + ); + const settleCall = mockSettleOperation.mock.calls[0]?.[1] as { + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleCall?.outboxEvent).toMatchObject({ + eventName: 'organization_write_settled', + properties: { intent: 'member_remove', outcome: 'completed' }, + }); + expect(mockRevokeGatewayStateForOrganizationMember).toHaveBeenCalledWith( + expect.anything(), + ORG_ID, + MEMBER_ID + ); + }); + + it('settles the row failed without success audit or outbox when the helper removes nothing', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockRemoveUserFromOrganization.mockResolvedValue({ rowCount: 0 }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + + await expect(caller.remove(input)).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Failed to remove user from organization', + }); + + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'failed', + outcomeCode: 'member_absent', + }) + ); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + expect(mockRevokeGatewayStateForOrganizationMember).not.toHaveBeenCalled(); + }); + + it('replays a settled duplicate without re-running the helper', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_settled', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + status: 'completed', + canonical_result: { updated: MEMBER_ID }, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + + const result = await caller.remove(input); + + expect(result).toEqual({ success: true, updated: MEMBER_ID, replayed: true }); + expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); + expect(mockSettleOperation).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + }); + + it('conflicts on an in-flight duplicate instead of re-running the helper', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'duplicate_in_flight', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + + await expect(caller.remove(input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_in_progress', + }); + expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); + }); + }); + + describe('remove: read-back takeover repair for member removal', () => { + const input = { + organizationId: ORG_ID, + memberId: MEMBER_ID, + operationKey: 'org-op-key-1', + }; + + it('completes the record and replays when the read-back shows the member already gone', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'takeover', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + mockDbState.memberReadBack = []; + + const result = await caller.remove(input); + + expect(result).toEqual({ success: true, updated: MEMBER_ID, replayed: true }); + expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.member.remove', tx }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ rowId: 'org-ledger-row-id', status: 'completed' }) + ); + expect(mockRevokeGatewayStateForOrganizationMember).toHaveBeenCalledWith( + expect.anything(), + ORG_ID, + MEMBER_ID + ); + }); + + it('re-runs the helper under the same row when the read-back still shows the member', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'takeover', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + mockDbState.memberReadBack = [{ id: MEMBER_ID }]; + + const result = await caller.remove(input); + + expect(mockRemoveUserFromOrganization).toHaveBeenCalledWith( + ORG_ID, + MEMBER_ID, + 'owner-user-1' + ); + expect(result).toEqual({ success: true, updated: MEMBER_ID, replayed: true }); + expect(mockSettleOperation).toHaveBeenCalledWith( + tx, + expect.objectContaining({ rowId: 'org-ledger-row-id', status: 'completed' }) + ); + }); + }); +}); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index 36400e13c4..f99002a402 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -13,8 +13,9 @@ import { organization_invitations, kilocode_users, organizations, + type OperationLedgerRow, } from '@kilocode/db/schema'; -import { db, sql } from '@/lib/drizzle'; +import { db, sql, type DrizzleTransaction } from '@/lib/drizzle'; import { createTRPCRouter } from '@/lib/trpc/init'; import { ensureOrganizationAccess, @@ -29,22 +30,33 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; import * as z from 'zod'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; import { findUserById } from '@/lib/user'; -import { successResult } from '@/lib/maybe-result'; +import { successResult, type SuccessResult } from '@/lib/maybe-result'; import { destroyOrgInstancesForUser } from '@/lib/kiloclaw/instance-registry'; import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; import { revokeGatewayStateForOrganizationMember } from '@/lib/mcp-gateway/lifecycle-service'; import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types'; +import { + admitOperation, + settleOperation, + type OutboxEventInput, +} from '@kilocode/db/operation-ledger'; const MAX_DAILY_LIMIT_USD = 2000; +const operationKeySchema = z.string().min(1).max(128).optional(); + const UpdateMemberSchema = OrganizationIdInputSchema.extend({ memberId: z.string(), role: z.enum(['owner', 'member', 'billing_manager']).optional(), dailyUsageLimitUsd: z.number().min(0).max(MAX_DAILY_LIMIT_USD).nullable().optional(), + /** Optional client-generated per-intent key for the role-change ledger (P1-A-08e). */ + operationKey: operationKeySchema, }); const RemoveMemberSchema = OrganizationIdInputSchema.extend({ memberId: z.string(), + /** Optional client-generated per-intent key for the member-removal ledger (P1-A-08e). */ + operationKey: operationKeySchema, }); const InviteMemberSchema = OrganizationIdInputSchema.extend({ @@ -79,6 +91,430 @@ async function getDirectOrganizationRole( return membership?.role ?? null; } +// --------------------------------------------------------------------------- +// Organization operation ledger (P1-A-08e) +// --------------------------------------------------------------------------- +// +// The role-change (`update` with `role`) and member-removal (`remove`) +// mutations accept an optional `operationKey`. When present, the mutation +// admits an `organization`-domain ledger row BEFORE running the membership +// helper, and only then executes. Later same-key calls dedupe, replay the +// canonical result, or conflict. After a successful helper commit, the success +// audit log, the terminal settle, and the `organization_write_settled` outbox +// event are written in ONE transaction (atomic). A failed helper result +// settles the row `failed` without a success audit or success outbox. A +// takeover/reconcile retry reads the membership back FIRST: an already-applied +// role or an already-removed member settles completed and replays, which +// avoids the NOT_FOUND trap of re-running the helper on the already-applied +// state. The membership helpers and the `dailyUsageLimitUsd` path are +// unchanged. + +const ORG_LEDGER_DOMAIN = 'organization' as const; +/** The in-flight window: while an `admitted` row holds a live lease, same-key + * retries receive CONFLICT `operation_in_progress` instead of re-running. */ +const ORG_LEDGER_LEASE_SECONDS = 120; + +const ORG_LEDGER_INTENTS = ['member_role_change', 'member_remove'] as const; +type OrgLedgerIntent = (typeof ORG_LEDGER_INTENTS)[number]; + +const ORG_OPERATION_IN_PROGRESS_MESSAGE = 'operation_in_progress'; +const ORG_REPLAY_FAILED_MESSAGE = 'This action did not complete. Please try again.'; +const ORG_OPERATION_KEY_REUSE_MISMATCH_MESSAGE = 'operation_key_reuse_mismatch'; +// A provider-confirmed outcome whose ledger settle failed: the membership +// helper DID commit, but the row was not settled. Never surface a success +// receipt for an un-recorded row — a same-key retry repairs by read-back. +const ORG_LEDGER_SETTLE_FAILED_MESSAGE = + 'The action completed, but we could not record the result. Please try again.'; + +function orgOperationInProgressError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: ORG_OPERATION_IN_PROGRESS_MESSAGE }); +} + +function orgOperationKeyReuseMismatchError(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: ORG_OPERATION_KEY_REUSE_MISMATCH_MESSAGE }); +} + +/** `organization_write_settled` outbox payload (DEC-05): no free text, no resource keys. */ +function orgSettledOutboxEvent(params: { + distinctId: string; + intent: OrgLedgerIntent; + outcome: 'completed' | 'failed'; +}): OutboxEventInput { + return { + eventName: 'organization_write_settled', + distinctId: params.distinctId, + properties: { + source: 'web', + surface: 'organization', + phase: 'terminal', + intent: params.intent, + outcome: params.outcome, + }, + }; +} + +function orgMemberRoleResourceKey(organizationId: string, memberId: string, role: string): string { + return `organization:${organizationId}:member:${memberId}:role:${role}`; +} + +function orgMemberRemoveResourceKey(organizationId: string, memberId: string): string { + return `organization:${organizationId}:member:${memberId}`; +} + +/** + * Best-effort ledger write, reserved for FAILED-status settles only: the + * caller is already receiving a typed rejection, so a ledger write that fails + * here must never mask the helper outcome. + */ +async function bestEffortOrgLedgerWrite(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + console.error( + `Failed to write organization operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** Replays a terminal row: only `completed`/`no_op` may replay a canonical result. */ +function replaySettledOrgRow(row: OperationLedgerRow): T { + if (row.status === 'completed' || row.status === 'no_op') { + return { success: true, ...(row.canonical_result ?? {}), replayed: true } as T; + } + throw new TRPCError({ code: 'BAD_REQUEST', message: ORG_REPLAY_FAILED_MESSAGE }); +} + +/** + * Durably settles a provider-confirmed org outcome as `completed` with the + * success outbox event inside the SAME transaction as the success audit log. + * A settle failure must never yield a success receipt for an un-recorded row. + */ +async function settleOrgCompletedInTransaction(args: { + tx: DrizzleTransaction; + row: OperationLedgerRow; + canonicalResult: Record; + outboxEvent: OutboxEventInput; +}): Promise { + try { + await settleOperation(args.tx, { + rowId: args.row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: args.canonicalResult, + outboxEvent: args.outboxEvent, + }); + } catch (error) { + console.error( + `Failed to settle completed organization operation ledger row: ${error instanceof Error ? error.message : String(error)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: ORG_LEDGER_SETTLE_FAILED_MESSAGE, + cause: error, + }); + } +} + +type OrgActor = { + id: string; + google_user_email: string | null; + google_user_name: string | null; + is_admin: boolean; +}; + +/** + * Runs the role-change helper under an already-admitted row and, after the + * helper commits, writes the success audit log + terminal settle + outbox in + * one transaction. A failed helper result settles the row `failed` without a + * success audit or success outbox. + */ +async function executeOrgRoleChange(args: { + row: OperationLedgerRow; + user: OrgActor; + organizationId: string; + memberId: string; + role: 'owner' | 'member' | 'billing_manager'; + dailyUsageLimitUsd?: number | null; + targetMember?: { role: string }; +}): Promise> { + const { organizationId, memberId, role } = args; + const distinctId = args.user.google_user_email || args.user.id; + + const result = await updateUserRoleInOrganization(organizationId, memberId, role); + if (!result.success) { + await bestEffortOrgLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: 'role_change_failed', + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_role_change', + outcome: 'failed', + }), + }) + ); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Failed to update user role', + }); + } + + if (args.dailyUsageLimitUsd !== undefined && args.targetMember) { + await updateOrganizationUserLimit(organizationId, memberId, args.dailyUsageLimitUsd); + } + + const updatedUser = await findUserById(memberId); + const updatedUserEmail = updatedUser?.google_user_email || 'unknown'; + const updated = 'role and limit'; + + await db.transaction(async tx => { + await createAuditLog({ + action: 'organization.member.change_role', + actor_email: args.user.google_user_email, + actor_id: args.user.id, + actor_name: args.user.google_user_name, + message: `Changed role for user ${updatedUserEmail} from ${args.targetMember?.role ?? 'unknown'} to ${role}`, + organization_id: organizationId, + tx, + }); + await settleOrgCompletedInTransaction({ + tx, + row: args.row, + canonicalResult: { updated }, + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_role_change', + outcome: 'completed', + }), + }); + }); + + return successResult({ updated }); +} + +/** + * Read-back-first takeover repair for a role change: if the read-back shows + * the target role already applied, the first attempt committed the change + * without settling — settle completed and replay without re-running the + * helper (avoiding the NOT_FOUND trap). If the member is gone, settle failed. + * Otherwise re-run the helper under the same row. + */ +async function repairOrgRoleChange(args: { + row: OperationLedgerRow; + user: OrgActor; + organizationId: string; + memberId: string; + role: 'owner' | 'member' | 'billing_manager'; + dailyUsageLimitUsd?: number | null; +}): Promise> { + const distinctId = args.user.google_user_email || args.user.id; + const [membership] = await db + .select({ role: organization_memberships.role }) + .from(organization_memberships) + .where( + and( + eq(organization_memberships.organization_id, args.organizationId), + eq(organization_memberships.kilo_user_id, args.memberId) + ) + ) + .limit(1); + + if (!membership) { + await bestEffortOrgLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: 'member_absent', + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_role_change', + outcome: 'failed', + }), + }) + ); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User is not a member of this organization', + }); + } + + if (membership.role === args.role) { + const updatedUser = await findUserById(args.memberId); + const updatedUserEmail = updatedUser?.google_user_email || 'unknown'; + await db.transaction(async tx => { + await createAuditLog({ + action: 'organization.member.change_role', + actor_email: args.user.google_user_email, + actor_id: args.user.id, + actor_name: args.user.google_user_name, + message: `Changed role for user ${updatedUserEmail} from ${membership.role} to ${args.role}`, + organization_id: args.organizationId, + tx, + }); + await settleOrgCompletedInTransaction({ + tx, + row: args.row, + canonicalResult: { updated: 'role and limit' }, + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_role_change', + outcome: 'completed', + }), + }); + }); + return successResult({ updated: 'role and limit', replayed: true }); + } + + // Not applied yet: re-run the helper under the same row (takeover). + return executeOrgRoleChange({ + ...args, + targetMember: { role: membership.role }, + }); +} + +/** KiloClaw instance cleanup after a member removal (existing behavior, extracted). */ +async function cleanupRemovedMemberInstances( + memberId: string, + organizationId: string +): Promise { + // Runs after the membership deletion transaction commits. + // Fire-and-forget worker calls — Postgres rows are already soft-deleted, + // so even if worker calls fail the instance is "dead" from the platform + // perspective and reconciliation will clean up. + try { + const destroyedInstances = await destroyOrgInstancesForUser(memberId, organizationId); + if (destroyedInstances.length > 0) { + const client = new KiloClawInternalClient(); + const results = await Promise.allSettled( + destroyedInstances.map(({ instanceId }) => + client.destroy(memberId, instanceId, { reason: 'org_member_cleanup' }) + ) + ); + for (const [i, result] of results.entries()) { + if (result.status === 'rejected') { + console.error( + `[kiloclaw-org] Failed to destroy worker instance ${destroyedInstances[i].instanceId} for removed member ${memberId}:`, + result.reason + ); + } + } + console.log( + `[kiloclaw-org] Destroyed ${destroyedInstances.length} instance(s) for removed member ${memberId} in org ${organizationId}` + ); + } + } catch (err) { + console.error( + `[kiloclaw-org] Failed to clean up KiloClaw instances for removed member ${memberId}:`, + err + ); + } +} + +/** Transactional success audit + settle + outbox, then existing post-removal side effects. */ +async function completeOrgMemberRemoval(args: { + row: OperationLedgerRow; + user: OrgActor; + organizationId: string; + memberId: string; +}): Promise> { + const distinctId = args.user.google_user_email || args.user.id; + const removedUser = await findUserById(args.memberId); + + await db.transaction(async tx => { + await createAuditLog({ + action: 'organization.member.remove', + actor_email: args.user.google_user_email, + actor_id: args.user.id, + actor_name: args.user.google_user_name, + message: `Removed user ${removedUser?.google_user_email || 'unknown'}`, + organization_id: args.organizationId, + tx, + }); + await settleOrgCompletedInTransaction({ + tx, + row: args.row, + canonicalResult: { updated: args.memberId }, + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_remove', + outcome: 'completed', + }), + }); + }); + + await revokeGatewayStateForOrganizationMember(db, args.organizationId, args.memberId); + await cleanupRemovedMemberInstances(args.memberId, args.organizationId); + + return successResult({ updated: args.memberId }); +} + +/** + * Runs the removal helper under an already-admitted row. A `rowCount` of zero + * means the member was already gone (never satisfied under the `admitted` + * path): settle failed and surface the existing NOT_FOUND rejection. + */ +async function executeOrgMemberRemove(args: { + row: OperationLedgerRow; + user: OrgActor; + organizationId: string; + memberId: string; +}): Promise> { + const distinctId = args.user.google_user_email || args.user.id; + const result = await removeUserFromOrganization(args.organizationId, args.memberId, args.user.id); + if (result.rowCount === 0) { + await bestEffortOrgLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: 'member_absent', + outboxEvent: orgSettledOutboxEvent({ + distinctId, + intent: 'member_remove', + outcome: 'failed', + }), + }) + ); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Failed to remove user from organization', + }); + } + return completeOrgMemberRemoval(args); +} + +/** + * Read-back-first takeover repair for a member removal: if the read-back shows + * the member already gone, the first attempt committed the removal without + * settling — complete the record (audit + settle + outbox + side effects) and + * replay without re-running the helper (avoiding the NOT_FOUND trap). + * Otherwise re-run the helper under the same row. + */ +async function repairOrgMemberRemove(args: { + row: OperationLedgerRow; + user: OrgActor; + organizationId: string; + memberId: string; +}): Promise> { + const [membership] = await db + .select({ id: organization_memberships.id }) + .from(organization_memberships) + .where( + and( + eq(organization_memberships.organization_id, args.organizationId), + eq(organization_memberships.kilo_user_id, args.memberId) + ) + ) + .limit(1); + + if (!membership) { + const completed = await completeOrgMemberRemoval(args); + return { ...completed, replayed: true }; + } + const completed = await executeOrgMemberRemove(args); + return { ...completed, replayed: true }; +} + export const organizationsMembersRouter = createTRPCRouter({ listPublic: organizationMemberProcedure .input(OrganizationIdInputSchema) @@ -91,11 +527,19 @@ export const organizationsMembersRouter = createTRPCRouter({ .input(UpdateMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; - const { organizationId, memberId, role, dailyUsageLimitUsd } = input; + const { organizationId, memberId, role, dailyUsageLimitUsd, operationKey } = input; // Get the target user's role if we need to check permissions for role or limit changes let targetMember: { role: string } | undefined; if (role !== undefined || dailyUsageLimitUsd !== undefined) { + // Prevent users from changing their own role + if (role !== undefined && user.id === memberId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You cannot change your own role', + }); + } + const [member] = await db .select({ role: organization_memberships.role }) .from(organization_memberships) @@ -116,16 +560,16 @@ export const organizationsMembersRouter = createTRPCRouter({ targetMember = member; } - // Handle role update if provided - if (role !== undefined) { - // Prevent users from changing their own role - if (user.id === memberId) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'You cannot change your own role', - }); + // Limit-only update: existing path exactly, no ledger. + if (role === undefined) { + if (dailyUsageLimitUsd !== undefined && targetMember) { + await updateOrganizationUserLimit(organizationId, memberId, dailyUsageLimitUsd); } + return successResult({ updated: 'limit' }); + } + // Role change without an operationKey: existing path exactly. + if (operationKey === undefined) { const result = await updateUserRoleInOrganization(organizationId, memberId, role); const updatedUser = await findUserById(memberId); const updatedUserEmail = updatedUser?.google_user_email || 'unknown'; @@ -144,16 +588,60 @@ export const organizationsMembersRouter = createTRPCRouter({ message: 'Failed to update user role', }); } - } - // Handle daily usage limit update if provided - if (dailyUsageLimitUsd !== undefined && targetMember) { - await updateOrganizationUserLimit(organizationId, memberId, dailyUsageLimitUsd); + if (dailyUsageLimitUsd !== undefined && targetMember) { + await updateOrganizationUserLimit(organizationId, memberId, dailyUsageLimitUsd); + } + + return successResult({ updated: 'role and limit' }); } - return successResult({ - updated: role !== undefined ? 'role and limit' : 'limit', + // Role change with an operationKey: admit a ledger row before running + // the helper (P1-A-08e). + const resourceKey = orgMemberRoleResourceKey(organizationId, memberId, role); + const admission = await admitOperation(db, { + userId: user.id, + orgId: organizationId, + domain: ORG_LEDGER_DOMAIN, + intent: 'member_role_change', + operationKey, + resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: ORG_LEDGER_LEASE_SECONDS, }); + if ( + admission.row.intent !== 'member_role_change' || + admission.row.resource_key !== resourceKey + ) { + throw orgOperationKeyReuseMismatchError(); + } + switch (admission.admission) { + case 'admitted': + return executeOrgRoleChange({ + row: admission.row, + user, + organizationId, + memberId, + role, + dailyUsageLimitUsd, + targetMember, + }); + case 'takeover': + case 'duplicate_reconcile_pending': + return repairOrgRoleChange({ + row: admission.row, + user, + organizationId, + memberId, + role, + dailyUsageLimitUsd, + }); + case 'duplicate_settled': + return replaySettledOrgRow(admission.row); + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw orgOperationInProgressError(); + } }), setChildMemberships: organizationBillingMutationProcedure .input(SetChildMembershipsSchema) @@ -287,7 +775,7 @@ export const organizationsMembersRouter = createTRPCRouter({ .input(RemoveMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; - const { organizationId, memberId } = input; + const { organizationId, memberId, operationKey } = input; // Prevent users from removing themselves (unless they are kilo admin users) if (user.id === memberId && !user.is_admin) { @@ -327,61 +815,70 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - const result = await removeUserFromOrganization(organizationId, memberId, user.id); - if (result.rowCount === 0) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Failed to remove user from organization', - }); - } - - const removedUser = await findUserById(memberId); - await createAuditLog({ - action: 'organization.member.remove', - actor_email: user.google_user_email, - actor_id: user.id, - actor_name: user.google_user_name, - message: `Removed user ${removedUser?.google_user_email || 'unknown'}`, - organization_id: organizationId, - }); + // Without an operationKey, use the existing path exactly. + if (operationKey === undefined) { + const result = await removeUserFromOrganization(organizationId, memberId, user.id); + if (result.rowCount === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Failed to remove user from organization', + }); + } - await revokeGatewayStateForOrganizationMember(db, organizationId, memberId); + const removedUser = await findUserById(memberId); + await createAuditLog({ + action: 'organization.member.remove', + actor_email: user.google_user_email, + actor_id: user.id, + actor_name: user.google_user_name, + message: `Removed user ${removedUser?.google_user_email || 'unknown'}`, + organization_id: organizationId, + }); - // KiloClaw cleanup: destroy org instances assigned to the removed member. + await revokeGatewayStateForOrganizationMember(db, organizationId, memberId); + await cleanupRemovedMemberInstances(memberId, organizationId); - // Runs after the membership deletion transaction commits. - // Fire-and-forget worker calls — Postgres rows are already soft-deleted, - // so even if worker calls fail the instance is "dead" from the platform - // perspective and reconciliation will clean up. - try { - const destroyedInstances = await destroyOrgInstancesForUser(memberId, organizationId); - if (destroyedInstances.length > 0) { - const client = new KiloClawInternalClient(); - const results = await Promise.allSettled( - destroyedInstances.map(({ instanceId }) => - client.destroy(memberId, instanceId, { reason: 'org_member_cleanup' }) - ) - ); - for (const [i, result] of results.entries()) { - if (result.status === 'rejected') { - console.error( - `[kiloclaw-org] Failed to destroy worker instance ${destroyedInstances[i].instanceId} for removed member ${memberId}:`, - result.reason - ); - } - } - console.log( - `[kiloclaw-org] Destroyed ${destroyedInstances.length} instance(s) for removed member ${memberId} in org ${organizationId}` - ); - } - } catch (err) { - console.error( - `[kiloclaw-org] Failed to clean up KiloClaw instances for removed member ${memberId}:`, - err - ); + return successResult({ updated: memberId }); } - return successResult({ updated: memberId }); + // With an operationKey, admit a ledger row before running the helper + // (P1-A-08e). + const resourceKey = orgMemberRemoveResourceKey(organizationId, memberId); + const admission = await admitOperation(db, { + userId: user.id, + orgId: organizationId, + domain: ORG_LEDGER_DOMAIN, + intent: 'member_remove', + operationKey, + resourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: ORG_LEDGER_LEASE_SECONDS, + }); + if (admission.row.intent !== 'member_remove' || admission.row.resource_key !== resourceKey) { + throw orgOperationKeyReuseMismatchError(); + } + switch (admission.admission) { + case 'admitted': + return executeOrgMemberRemove({ + row: admission.row, + user, + organizationId, + memberId, + }); + case 'takeover': + case 'duplicate_reconcile_pending': + return repairOrgMemberRemove({ + row: admission.row, + user, + organizationId, + memberId, + }); + case 'duplicate_settled': + return replaySettledOrgRow(admission.row); + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw orgOperationInProgressError(); + } }), invite: organizationBillingMutationProcedure .input(InviteMemberSchema) diff --git a/services/security-sync/src/index.test.ts b/services/security-sync/src/index.test.ts index 680a4c3da5..dcf45e514e 100644 --- a/services/security-sync/src/index.test.ts +++ b/services/security-sync/src/index.test.ts @@ -7,6 +7,7 @@ import { } from '@kilocode/db'; import type * as DbModule from '@kilocode/db'; import { getWorkerDb } from '@kilocode/db/client'; +import { settleOperation } from '@kilocode/db/operation-ledger'; import worker, { collectScheduledSyncOwners, type SecuritySyncQueueMessage } from './index.js'; import { processSecurityFindingDismissal } from './dismiss.js'; import { runSecurityNotificationSweep } from './notifications/sweep.js'; @@ -27,6 +28,7 @@ vi.mock('@kilocode/db', async importOriginal => { }; }); vi.mock('@kilocode/db/client', () => ({ getWorkerDb: vi.fn() })); +vi.mock('@kilocode/db/operation-ledger', () => ({ settleOperation: vi.fn() })); vi.mock('./dismiss.js', () => ({ processSecurityFindingDismissal: vi.fn() })); vi.mock('./notifications/sweep.js', () => ({ runSecurityNotificationSweep: vi.fn() })); vi.mock('./sync.js', () => ({ syncOwner: vi.fn() })); @@ -824,3 +826,173 @@ describe('manual dismissal dispatch', () => { expect(retry).toHaveBeenCalledTimes(1); }); }); + +describe('security operation ledger provider_ref join', () => { + const messageId = 'manual-sync-message-123'; + const actor = { id: 'user-123', email: 'owner@example.com', name: 'Owner Example' }; + + function ledgerLookupDb(rows: { id: string }[]) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => rows, + }), + }), + }), + } as never; + } + + function syncQueueMessage(overrides: Record = {}) { + return { + schemaVersion: 1, + commandId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + messageId, + trigger: 'manual', + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + ownerKey: 'org:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + chunkIndex: 0, + chunkCount: 1, + dispatchedAt: '2026-06-11T10:00:00.000Z', + actor, + ...overrides, + }; + } + + async function processSyncMessage( + overrides: Record = {}, + attempts = 1 + ): Promise<{ ack: ReturnType; retry: ReturnType }> { + const ack = vi.fn(); + const retry = vi.fn(); + await worker.queue( + { + messages: [{ attempts, body: syncQueueMessage(overrides), ack, retry }], + } as never, + { + HYPERDRIVE: { connectionString: 'postgres://worker' }, + GIT_TOKEN_SERVICE: {}, + } as CloudflareEnv + ); + return { ack, retry }; + } + + it('settles a succeeded manual sync row as completed via the provider reference', async () => { + vi.mocked(getWorkerDb).mockReturnValue(ledgerLookupDb([{ id: 'ledger-row-id' }])); + vi.mocked(syncOwner).mockResolvedValue({ synced: 3, errors: 0, staleRepos: 0 } as never); + + await processSyncMessage(); + + expect(settleOperation).toHaveBeenCalledTimes(1); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-id', + status: 'completed', + outcomeCode: 'SYNC_COMPLETED', + canonicalResult: { repo_count: 3, error_count: 0 }, + }) + ); + const settleCall = vi.mocked(settleOperation).mock.calls[0]?.[1] as { + outboxEvent?: { + eventName: string; + distinctId: string; + properties: Record; + }; + }; + expect(settleCall?.outboxEvent).toMatchObject({ + eventName: 'security_command_settled', + distinctId: 'owner@example.com', + properties: { + source: 'server', + surface: 'security', + phase: 'terminal', + intent: 'manual_sync', + outcome: 'completed', + repo_count: 3, + error_count: 0, + }, + }); + }); + + it('settles a failed manual sync row as failed with the partial-failure result code', async () => { + vi.mocked(getWorkerDb).mockReturnValue(ledgerLookupDb([{ id: 'ledger-row-id' }])); + vi.mocked(syncOwner).mockResolvedValue({ synced: 1, errors: 2, staleRepos: 0 } as never); + + await processSyncMessage(); + + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-id', + status: 'failed', + outcomeCode: 'SYNC_PARTIAL_FAILURE', + }) + ); + }); + + it('settles a no-op manual sync row as no_op with the disabled result code', async () => { + vi.mocked(getWorkerDb).mockReturnValue(ledgerLookupDb([{ id: 'ledger-row-id' }])); + vi.mocked(syncOwner).mockResolvedValue({ + synced: 0, + errors: 0, + staleRepos: [], + commandResultCode: 'CONFIG_DISABLED', + } as never); + + await processSyncMessage(); + + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-id', + status: 'no_op', + outcomeCode: 'CONFIG_DISABLED', + }) + ); + }); + + it('skips the settle when no ledger row matches the provider reference', async () => { + vi.mocked(getWorkerDb).mockReturnValue(ledgerLookupDb([])); + vi.mocked(syncOwner).mockResolvedValue({ synced: 1, errors: 0, staleRepos: 0 } as never); + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + await processSyncMessage(); + + expect(settleOperation).not.toHaveBeenCalled(); + expect( + info.mock.calls.find( + ([message]) => + typeof message === 'string' && message.includes('row not found for provider ref') + )?.[0] + ).toBe('Security operation ledger row not found for provider ref; skipping settle'); + info.mockRestore(); + }); + + it('settles the row failed with retry-exhaustion after final delivery failure', async () => { + vi.mocked(getWorkerDb).mockReturnValue(ledgerLookupDb([{ id: 'ledger-row-id' }])); + vi.mocked(syncOwner).mockRejectedValue(new Error('sync processing failed')); + vi.mocked(markSecurityAgentCommandRetriesExhausted).mockResolvedValueOnce({ + transitioned: false, + command: { status: 'failed', result_code: 'QUEUE_RETRIES_EXHAUSTED' }, + } as never); + + const { ack, retry } = await processSyncMessage({}, 4); + + expect(markSecurityAgentCommandRetriesExhausted).toHaveBeenCalledWith( + expect.anything(), + 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + ); + expect(settleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-id', + status: 'failed', + outcomeCode: 'QUEUE_RETRIES_EXHAUSTED', + }) + ); + expect(ack).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + }); +}); diff --git a/services/security-sync/src/index.ts b/services/security-sync/src/index.ts index 6226bdaa00..6b0819b464 100644 --- a/services/security-sync/src/index.ts +++ b/services/security-sync/src/index.ts @@ -11,7 +11,8 @@ import { type SecurityAgentCommandTransitionOutcome, } from '@kilocode/db'; import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; -import { agent_configs } from '@kilocode/db/schema'; +import { agent_configs, kilocode_users, operation_ledgers } from '@kilocode/db/schema'; +import { settleOperation } from '@kilocode/db/operation-ledger'; import { buildScheduledJobFailureEvent, buildScheduledJobSuccessEvent, @@ -370,6 +371,174 @@ function syncCommandTerminalState(result: Awaited>) return { status: 'succeeded', resultCode: 'SYNC_COMPLETED' }; } +// ----- security operation ledger join (P1-A-08e) ---------------------------- +// +// Manual sync and dismissal commands admitted with an `operationKey` store the +// Worker `messageId` in `operation_ledgers.provider_ref` at acceptance. After +// the command reaches a terminal state, the Worker joins that row by provider +// reference and settles it with the security terminal mapping +// (succeeded→completed, failed→failed, no_op→no_op) plus a durable +// `security_command_settled` outbox event written atomically by +// `settleOperation`. Rows that are missing or already terminal are skipped: +// scheduled syncs and keyless commands have no ledger row, and a second settle +// of a terminal row is a no-op by ledger design. + +const SECURITY_TERMINAL_STATUS_MAP = { + succeeded: 'completed', + failed: 'failed', + no_op: 'no_op', +} as const; + +function isTerminalSecurityLedgerStatus( + status: string +): status is keyof typeof SECURITY_TERMINAL_STATUS_MAP { + return status === 'succeeded' || status === 'failed' || status === 'no_op'; +} + +/** Resolves the analytics identity channel (user email) for the outbox event. */ +async function resolveSecuritySettleDistinctId( + db: WorkerDb, + params: { userId?: string; email?: string | null } +): Promise { + if (params.email) return params.email; + if (!params.userId) return 'unknown'; + try { + const [user] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, params.userId)) + .limit(1); + return user?.email ?? params.userId; + } catch (error) { + console.error('Failed to resolve security settle distinct id', { + error_type: error instanceof Error ? error.name : 'UnknownError', + }); + return params.userId; + } +} + +/** + * Settles the ledger row joined by `provider_ref = messageId`. Missing rows + * skip; the settle is best-effort (a failure never re-queues the message — the + * command already reached a terminal state). Terminal rows are no-ops. + */ +async function settleSecurityLedgerByProviderRef( + db: WorkerDb, + params: { + providerRef: string; + intent: 'manual_sync' | 'dismiss_finding'; + status: 'succeeded' | 'failed' | 'no_op'; + resultCode: string; + userId?: string; + actorEmail?: string | null; + dispatchedAt: string; + repoCount?: number; + errorCount?: number; + } +): Promise { + let row: { id: string } | undefined; + try { + const rows = await db + .select({ id: operation_ledgers.id }) + .from(operation_ledgers) + .where(eq(operation_ledgers.provider_ref, params.providerRef)) + .limit(1); + row = rows[0]; + } catch (error) { + console.error('Security operation ledger lookup failed', { + provider_ref: params.providerRef, + error_type: error instanceof Error ? error.name : 'UnknownError', + }); + return; + } + if (!row) { + console.info('Security operation ledger row not found for provider ref; skipping settle', { + provider_ref: params.providerRef, + intent: params.intent, + result_code: params.resultCode, + }); + return; + } + + const status = SECURITY_TERMINAL_STATUS_MAP[params.status]; + const dispatched = new Date(params.dispatchedAt).getTime(); + const durationMs = Number.isFinite(dispatched) ? Math.max(0, Date.now() - dispatched) : 0; + const distinctId = await resolveSecuritySettleDistinctId(db, { + userId: params.userId, + email: params.actorEmail, + }); + + try { + await settleOperation(db, { + rowId: row.id, + status, + outcomeCode: params.resultCode, + canonicalResult: { + ...(params.repoCount !== undefined ? { repo_count: params.repoCount } : {}), + ...(params.errorCount !== undefined ? { error_count: params.errorCount } : {}), + }, + outboxEvent: { + eventName: 'security_command_settled', + distinctId, + properties: { + source: 'server', + surface: 'security', + phase: 'terminal', + intent: params.intent, + outcome: status, + ...(params.repoCount !== undefined ? { repo_count: params.repoCount } : {}), + ...(params.errorCount !== undefined ? { error_count: params.errorCount } : {}), + duration_ms: durationMs, + }, + }, + }); + console.info('Security operation ledger row settled', { + row_id: row.id, + provider_ref: params.providerRef, + intent: params.intent, + status, + result_code: params.resultCode, + }); + } catch (error) { + console.error('Failed to settle security operation ledger row', { + row_id: row.id, + status, + result_code: params.resultCode, + error_type: error instanceof Error ? error.name : 'UnknownError', + }); + } +} + +/** Extracts the ledger join identity from a queue message body, if present. */ +function ledgerSettleIdentityFromMessage(body: unknown): { + providerRef: string; + intent: 'manual_sync' | 'dismiss_finding'; + dispatchedAt: string; + userId?: string; + actorEmail?: string | null; +} | null { + const dismiss = SecurityDismissMessageSchema.safeParse(body); + if (dismiss.success) { + return { + providerRef: dismiss.data.messageId, + intent: 'dismiss_finding', + dispatchedAt: dismiss.data.dispatchedAt, + userId: dismiss.data.actor.id, + }; + } + const sync = SecuritySyncMessageSchema.safeParse(body); + if (sync.success) { + return { + providerRef: sync.data.messageId, + intent: 'manual_sync', + dispatchedAt: sync.data.dispatchedAt, + userId: sync.data.actor?.id, + actorEmail: sync.data.actor?.email, + }; + } + return null; +} + async function processSecurityDismissMessage( message: Message, env: CloudflareEnv @@ -391,6 +560,16 @@ async function processSecurityDismissMessage( result_code: running.command?.result_code, attempts: message.attempts, }); + if (running.command && isTerminalSecurityLedgerStatus(running.command.status)) { + await settleSecurityLedgerByProviderRef(db, { + providerRef: parsed.data.messageId, + intent: 'dismiss_finding', + status: running.command.status, + resultCode: running.command.result_code ?? 'UNKNOWN', + userId: parsed.data.actor.id, + dispatchedAt: parsed.data.dispatchedAt, + }); + } message.ack(); return true; } @@ -406,6 +585,14 @@ async function processSecurityDismissMessage( resultCode: result.resultCode, }); requireSecurityAgentCommandTransitionOrTerminal(terminal, 'terminal'); + await settleSecurityLedgerByProviderRef(db, { + providerRef: parsed.data.messageId, + intent: 'dismiss_finding', + status: result.commandStatus, + resultCode: result.resultCode, + userId: parsed.data.actor.id, + dispatchedAt: parsed.data.dispatchedAt, + }); console.info('Security Agent dismissal command completed', { command_id: parsed.data.commandId, command_type: 'dismiss_finding', @@ -459,6 +646,17 @@ async function processSecuritySyncMessage( result_code: running.command?.result_code, attempts: message.attempts, }); + if (running.command && isTerminalSecurityLedgerStatus(running.command.status)) { + await settleSecurityLedgerByProviderRef(db, { + providerRef: body.messageId, + intent: 'manual_sync', + status: running.command.status, + resultCode: running.command.result_code ?? 'UNKNOWN', + userId: body.actor?.id, + actorEmail: body.actor?.email, + dispatchedAt: body.dispatchedAt, + }); + } message.ack(); return; } @@ -488,6 +686,17 @@ async function processSecuritySyncMessage( }); requireSecurityAgentCommandTransitionOrTerminal(terminalTransition, 'terminal'); } + await settleSecurityLedgerByProviderRef(db, { + providerRef: body.messageId, + intent: 'manual_sync', + status: terminal.status, + resultCode: terminal.resultCode, + userId: body.actor?.id, + actorEmail: body.actor?.email, + dispatchedAt: body.dispatchedAt, + repoCount: result.synced, + errorCount: result.errors, + }); console.info('Security sync completed for owner', { command_id: body.commandId, command_type: body.commandId ? 'sync' : undefined, @@ -719,6 +928,18 @@ export default { correlation.commandId ); if (isTerminalSecurityAgentCommandTransitionOutcome(exhaustionOutcome)) { + const settleIdentity = ledgerSettleIdentityFromMessage(message.body); + if (settleIdentity) { + await settleSecurityLedgerByProviderRef(db, { + providerRef: settleIdentity.providerRef, + intent: settleIdentity.intent, + status: 'failed', + resultCode: 'QUEUE_RETRIES_EXHAUSTED', + userId: settleIdentity.userId, + actorEmail: settleIdentity.actorEmail, + dispatchedAt: settleIdentity.dispatchedAt, + }); + } console.info('Security Agent command delivery already terminal after failure', { command_id: correlation.commandId, command_type: correlation.commandType, From 5c22793210574d3af84998bb11c26c3da2ee406a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 09:14:36 +0200 Subject: [PATCH 10/56] feat(cloud-agent-sdk): dedupe remote session creation --- .../src/create-session.test.ts | 122 ++++++++++ .../cloud-agent-sdk/src/create-session.ts | 29 +++ packages/cloud-agent-sdk/src/transport.ts | 7 +- .../integration/user-connection-do.test.ts | 214 ++++++++++++++++++ services/session-ingest/test/test-worker.ts | 1 + .../session-ingest/vitest.workers.config.ts | 21 ++ services/session-ingest/wrangler.test.jsonc | 20 ++ 7 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 services/session-ingest/test/integration/user-connection-do.test.ts diff --git a/packages/cloud-agent-sdk/src/create-session.test.ts b/packages/cloud-agent-sdk/src/create-session.test.ts index c32e98d50f..cc7c2b992e 100644 --- a/packages/cloud-agent-sdk/src/create-session.test.ts +++ b/packages/cloud-agent-sdk/src/create-session.test.ts @@ -323,4 +323,126 @@ describe('createRemoteSessionOnConnection', () => { expectedConnectionId: 'cli-owner-1', }); }); + + it('forwards the caller mutationId as the extended wire identity (`${key}:ext`)', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockResolvedValue({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + }); + + await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(1); + expect(connection.sendCommandToConnection).toHaveBeenCalledWith({ + command: 'create_session', + data: { protocolVersion: 1, agent: 'code' }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'spawn-key-1:ext', + }); + }); + + it('uses the distinct bare identity (`${key}:bare`) for the old-CLI retry', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + + const result = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(2); + expect(connection.sendCommandToConnection).toHaveBeenNthCalledWith(1, { + command: 'create_session', + data: { protocolVersion: 1, agent: 'code' }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'spawn-key-1:ext', + }); + expect(connection.sendCommandToConnection).toHaveBeenNthCalledWith(2, { + command: 'create_session', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'spawn-key-1:bare', + }); + expect(parseCreateSessionResponse(result)).toEqual({ + ok: true, + kiloSessionId: VALID_SESSION_ID, + }); + }); + + it('keeps the extended and bare identities stable and distinct across attempts', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }) + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + + await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + const mutationIds = connection.sendCommandToConnection.mock.calls.map( + call => (call[0] as { mutationId?: string }).mutationId + ); + // Same key, same attempt → identical wire identity across calls. + expect(mutationIds).toEqual([ + 'spawn-key-1:ext', + 'spawn-key-1:bare', + 'spawn-key-1:ext', + 'spawn-key-1:bare', + ]); + // The two durable identities must never collide. + expect(new Set(mutationIds).size).toBe(2); + }); + + it('omits mutationId on both attempts when the caller provides none', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + + await createRemoteSessionOnConnection(connection, 'cli-owner-1', { agent: 'code' }); + + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(2); + for (const call of connection.sendCommandToConnection.mock.calls) { + expect(call[0]).not.toHaveProperty('mutationId'); + } + }); + + it('classifies a durable-replayed envelope identically to a live envelope', async () => { + // A D8 durable 'done' entry replays the exact stored envelope under the + // retry request's id. The classifier must produce the same output for the + // replayed envelope as for the original live delivery. + const envelope = { protocolVersion: 1, sessionID: VALID_SESSION_ID }; + + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockResolvedValue(envelope); + const live = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + connection.sendCommandToConnection.mockResolvedValue(envelope); + const replayed = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + expect(parseCreateSessionResponse(replayed)).toEqual(parseCreateSessionResponse(live)); + expect(parseCreateSessionResponse(replayed)).toEqual({ + ok: true, + kiloSessionId: VALID_SESSION_ID, + }); + }); }); diff --git a/packages/cloud-agent-sdk/src/create-session.ts b/packages/cloud-agent-sdk/src/create-session.ts index d31e45b2a3..5cf8ce4d9c 100644 --- a/packages/cloud-agent-sdk/src/create-session.ts +++ b/packages/cloud-agent-sdk/src/create-session.ts @@ -51,6 +51,25 @@ export function parseCreateSessionResponse(raw: unknown): CreateSessionParseResu */ export type CreateRemoteSessionRawResult = unknown; +/** Durable identity suffix for the extended `create_session` attempt. */ +const EXTENDED_MUTATION_ID_SUFFIX = ':ext'; + +/** Durable identity suffix for the old-CLI bare `create_session` retry. */ +const BARE_MUTATION_ID_SUFFIX = ':bare'; + +/** + * Derive the wire mutationId for one create attempt from the caller's stable + * key. The two attempts MUST NOT share a durable identity: the UserConnectionDO + * dedupes by mutationId, so a bare retry under the extended attempt's id would + * replay the stored `invalid create_session command` error instead of reaching + * the CLI. Appending a per-attempt suffix keeps both identities stable and + * distinct. `undefined` input keeps the legacy byte-identical wire (no + * mutationId). + */ +function attemptMutationId(key: string | undefined, suffix: string): string | undefined { + return key !== undefined ? `${key}${suffix}` : undefined; +} + /** * Connection-scoped `create_session` for the `kilo remote` process-per-session * spawn flow. Unlike the session-scoped `createSession` in @@ -59,6 +78,12 @@ export type CreateRemoteSessionRawResult = unknown; * `sessionId` on the wire — the CLI is expected to provision a fresh * `KiloSessionId` for the new cloud-agent session. * + * When `input.mutationId` is supplied it is forwarded on the wire as a durable + * dedupe identity (D8): the extended attempt uses `${key}:ext` and the old-CLI + * bare retry uses `${key}:bare`, so the two durable identities cannot collide + * (see `attemptMutationId`). When omitted, the wire carries no mutationId and + * the relay falls back to a per-wire random correlation id. + * * The returned promise resolves with the raw reply; the caller is responsible * for parsing the response shape. A delivered error response (string or * structured `UserWebCommandError`) rejects the promise; transport failures @@ -79,11 +104,13 @@ export async function createRemoteSessionOnConnection( }; const hasExtendedFields = data.agent !== undefined || data.model !== undefined || data.orgId !== undefined; + const extendedMutationId = attemptMutationId(input?.mutationId, EXTENDED_MUTATION_ID_SUFFIX); try { return await connection.sendCommandToConnection({ command: 'create_session', data, expectedConnectionId: connectionId, + ...(extendedMutationId !== undefined ? { mutationId: extendedMutationId } : {}), }); } catch (error) { // Only bare-retry when extended fields made the original wire differ from @@ -93,10 +120,12 @@ export async function createRemoteSessionOnConnection( error instanceof CommandDeliveredError && error.message === INVALID_CREATE_SESSION_COMMAND ) { + const bareMutationId = attemptMutationId(input?.mutationId, BARE_MUTATION_ID_SUFFIX); return connection.sendCommandToConnection({ command: 'create_session', data: { protocolVersion: 1 }, expectedConnectionId: connectionId, + ...(bareMutationId !== undefined ? { mutationId: bareMutationId } : {}), }); } throw error; diff --git a/packages/cloud-agent-sdk/src/transport.ts b/packages/cloud-agent-sdk/src/transport.ts index 7f188b28a7..49cd06aa70 100644 --- a/packages/cloud-agent-sdk/src/transport.ts +++ b/packages/cloud-agent-sdk/src/transport.ts @@ -16,7 +16,12 @@ import type { ModelRef, RemoteModelOverride } from './remote-model-catalog'; * connection-scoped spawn helper. */ type CreateRemoteSessionInput = { - /** Reuse for a caller retry of the same create intent. */ + /** + * Stable key for a caller retry of the same create intent. The + * connection-scoped spawn helper derives distinct durable wire identities + * from it (`${key}:ext` extended attempt, `${key}:bare` old-CLI retry) so + * the two attempts never collide in the relay's mutationId dedupe. + */ mutationId?: string; agent?: string; model?: { diff --git a/services/session-ingest/test/integration/user-connection-do.test.ts b/services/session-ingest/test/integration/user-connection-do.test.ts new file mode 100644 index 0000000000..451894a182 --- /dev/null +++ b/services/session-ingest/test/integration/user-connection-do.test.ts @@ -0,0 +1,214 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +import type { UserConnectionDO } from '../../src/dos/UserConnectionDO'; + +type JsonRecord = Record; + +type UserConnectionStub = ReturnType['get']>; + +type MessagePredicate = (message: JsonRecord) => boolean; + +/** + * Message collector for a client WebSocket. Buffers every inbound frame and + * lets the test await the next frame matching a predicate. + */ +function collectMessages(ws: WebSocket) { + const messages: JsonRecord[] = []; + const waiters: Array<{ + predicate: MessagePredicate; + resolve: (message: JsonRecord) => void; + reject: (error: Error) => void; + timer: ReturnType; + }> = []; + + ws.addEventListener('message', (event: MessageEvent) => { + const parsed = JSON.parse(String(event.data)) as JsonRecord; + messages.push(parsed); + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]; + if (waiter.predicate(parsed)) { + waiters.splice(i, 1); + clearTimeout(waiter.timer); + waiter.resolve(parsed); + return; + } + } + }); + + return { + messages, + count(predicate: MessagePredicate): number { + return messages.filter(predicate).length; + }, + next(predicate: MessagePredicate, timeoutMs = 5_000): Promise { + const existing = messages.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + let waiter: { + predicate: MessagePredicate; + resolve: (message: JsonRecord) => void; + reject: (error: Error) => void; + timer: ReturnType; + }; + const timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index !== -1) waiters.splice(index, 1); + reject(new Error(`Timed out waiting for a message matching the predicate`)); + }, timeoutMs); + waiter = { + predicate, + resolve, + reject, + timer, + }; + waiters.push(waiter); + }); + }, + }; +} + +/** + * Open a WebSocket to the UserConnectionDO at the given path (either `/cli` or + * `/web`) and return the accepted client socket. + */ +async function connectWs(stub: UserConnectionStub, path: string): Promise { + const response = await stub.fetch(`http://user-connection.test${path}`, { + headers: { Upgrade: 'websocket' }, + }); + expect(response.status).toBe(101); + if (!response.webSocket) { + throw new Error(`Expected a WebSocket in the upgrade response for ${path}`); + } + response.webSocket.accept(); + return response.webSocket; +} + +describe('UserConnectionDO integration', () => { + it('dedupes connection-scoped create_session by mutationId and replays the durable terminal envelope after hibernation', async () => { + const suffix = crypto.randomUUID().replaceAll('-', ''); + const doName = `ucdo-create-session-${suffix}`; + const stub = env.USER_CONNECTION_DO.get(env.USER_CONNECTION_DO.idFromName(doName)); + + // Stable caller key; the SDK maps it to `:ext` (extended attempt) and + // `:bare` (old-CLI retry) wire identities. + const extId = `spawn-${suffix}:ext`; + const sessionId = `ses_${suffix.slice(0, 26)}`; + + const cliWs = await connectWs(stub, `/cli?connectionId=cli-owner-1`); + const cli = collectMessages(cliWs); + cliWs.send(JSON.stringify({ type: 'heartbeat', protocolVersion: '1', sessions: [] })); + + const webWs1 = await connectWs(stub, `/web?connectionId=web-1`); + const web1 = collectMessages(webWs1); + + try { + // Connection-scoped create_session with the extended wire identity. + webWs1.send( + JSON.stringify({ + type: 'command', + id: 'req-1', + command: 'create_session', + connectionId: 'cli-owner-1', + mutationId: extId, + data: { protocolVersion: 1, agent: 'code' }, + }) + ); + + // The CLI receives the command with the mutation identity echoed on the + // wire (proves the SDK/relay forwards it for durable dedupe). + const cliCommand = await cli.next( + message => message.type === 'command' && message.command === 'create_session' + ); + expect(cliCommand).toEqual({ + type: 'command', + id: extId, + command: 'create_session', + mutationId: extId, + data: { protocolVersion: 1, agent: 'code' }, + }); + + // Duplicate send while the command is in flight: dedupe by mutationId. + // The CLI must not receive a second command. + webWs1.send( + JSON.stringify({ + type: 'command', + id: 'req-dup', + command: 'create_session', + connectionId: 'cli-owner-1', + mutationId: extId, + data: { protocolVersion: 1 }, + }) + ); + const dupResponse = await web1.next( + message => message.type === 'response' && message.id === 'req-dup' + ); + expect(dupResponse.error).toMatchObject({ code: 'COMMAND_ALREADY_PENDING' }); + expect(cli.count(message => message.type === 'command')).toBe(1); + + // The CLI answers with the live v1 envelope. + cliWs.send( + JSON.stringify({ + type: 'response', + id: extId, + result: { protocolVersion: 1, sessionID: sessionId }, + }) + ); + const liveEnvelope = await web1.next( + message => message.type === 'response' && message.id === 'req-1' + ); + expect(liveEnvelope).toEqual({ + type: 'response', + id: 'req-1', + result: { protocolVersion: 1, sessionID: sessionId }, + }); + + // The durable entry is terminal. + const durable = await runInDurableObject(stub, async (_instance, state) => + state.storage.get(`pendingCommand/${extId}`) + ); + expect(durable).toBeDefined(); + expect((durable as JsonRecord).state).toBe('done'); + expect((durable as JsonRecord).result).toEqual({ + protocolVersion: 1, + sessionID: sessionId, + }); + + // Simulated hibernation: the web side drops its socket (the durable + // entry survives — web disconnect keeps it) and a fresh web socket + // re-sends the same mutation identity. The DO must replay the durable + // terminal envelope without re-forwarding to the CLI. + webWs1.close(1000, 'hibernation drop'); + const webWs2 = await connectWs(stub, `/web?connectionId=web-2`); + const web2 = collectMessages(webWs2); + + webWs2.send( + JSON.stringify({ + type: 'command', + id: 'req-2', + command: 'create_session', + connectionId: 'cli-owner-1', + mutationId: extId, + data: { protocolVersion: 1 }, + }) + ); + + // Classifier-identical: the replayed durable envelope carries exactly + // the live envelope's result, under the new request id. + const replayedEnvelope = await web2.next( + message => message.type === 'response' && message.id === 'req-2' + ); + expect(replayedEnvelope).toEqual({ + type: 'response', + id: 'req-2', + result: { protocolVersion: 1, sessionID: sessionId }, + }); + + // The durable replay never re-forwards to the CLI. + expect(cli.count(message => message.type === 'command')).toBe(1); + } finally { + cliWs.close(1000, 'test complete'); + webWs1.close(1000, 'test complete'); + } + }); +}); diff --git a/services/session-ingest/test/test-worker.ts b/services/session-ingest/test/test-worker.ts index 6052f5c1ef..b7c142e936 100644 --- a/services/session-ingest/test/test-worker.ts +++ b/services/session-ingest/test/test-worker.ts @@ -1,5 +1,6 @@ export { SessionIngestDO } from '../src/dos/SessionIngestDO'; export { SessionAccessCacheDO } from '../src/dos/SessionAccessCacheDO'; +export { UserConnectionDO } from '../src/dos/UserConnectionDO'; export default { fetch(): Response { diff --git a/services/session-ingest/vitest.workers.config.ts b/services/session-ingest/vitest.workers.config.ts index ea42a013f2..da377ba2ad 100644 --- a/services/session-ingest/vitest.workers.config.ts +++ b/services/session-ingest/vitest.workers.config.ts @@ -1,9 +1,30 @@ +import { createRequire } from 'node:module'; + import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; import { defineConfig } from 'vitest/config'; +/** + * Redirect `pg-protocol` to its CommonJS build. Its package exports map points + * `import` at `esm/index.js`, a `.js` file with ESM syntax in a package that + * lacks `"type": "module"`. The Workers pool parses it as CommonJS and throws + * "Cannot use import statement outside a module" when `pg` requires it. + */ +function fixPgProtocolCjs() { + return { + name: 'fix-pg-protocol-cjs', + enforce: 'pre' as const, + resolveId(source: string, importer?: string) { + if (source !== 'pg-protocol' || importer === undefined) return undefined; + // Resolve from the requiring file so the pnpm virtual store is found. + return createRequire(importer).resolve('pg-protocol/dist/index.js'); + }, + }; +} + // Integration tests - run in Cloudflare Workers runtime via Miniflare export default defineConfig({ plugins: [ + fixPgProtocolCjs(), cloudflareTest({ wrangler: { configPath: './wrangler.test.jsonc', diff --git a/services/session-ingest/wrangler.test.jsonc b/services/session-ingest/wrangler.test.jsonc index ab982c86f3..7bbd35e2c8 100644 --- a/services/session-ingest/wrangler.test.jsonc +++ b/services/session-ingest/wrangler.test.jsonc @@ -24,6 +24,10 @@ "name": "SESSION_ACCESS_CACHE_DO", "class_name": "SessionAccessCacheDO", }, + { + "name": "USER_CONNECTION_DO", + "class_name": "UserConnectionDO", + }, ], }, @@ -32,6 +36,10 @@ "tag": "v1", "new_sqlite_classes": ["SessionIngestDO", "SessionAccessCacheDO"], }, + { + "tag": "v2", + "new_sqlite_classes": ["UserConnectionDO"], + }, ], "r2_buckets": [ @@ -41,6 +49,18 @@ }, ], + // Mirrors the production binding so ingest's live-persist path can read + // `env.HYPERDRIVE.connectionString`. The local connection string points at a + // Postgres that is not running in tests; the resulting failure is caught and + // logged by the DO, so tests still pass. + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "624ec80650dd414199349f4e217ddb10", + "localConnectionString": "postgres://postgres:postgres@localhost:5432/postgres", + }, + ], + "queues": { "producers": [ { From bc04d2e1f2c987e1cc5bbe13eb763a904a1cadef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 09:15:34 +0200 Subject: [PATCH 11/56] test(session-ingest): update attention signal assertion --- .../session-ingest/test/integration/session-ingest-do.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/session-ingest/test/integration/session-ingest-do.test.ts b/services/session-ingest/test/integration/session-ingest-do.test.ts index 25c3270d77..90e1a774eb 100644 --- a/services/session-ingest/test/integration/session-ingest-do.test.ts +++ b/services/session-ingest/test/integration/session-ingest-do.test.ts @@ -56,6 +56,7 @@ describe('SessionIngestDO integration', () => { await expect(stub.ingest(items, envelopeUserId, sessionId, 1, 1)).resolves.toEqual({ accepted: true, changes: [], + attentionSignals: [], }); await runInDurableObject(stub, async (_instance, state) => { const rows = [ From 368e07e21e31bfc921d3219669b7b241d6660ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 09:39:57 +0200 Subject: [PATCH 12/56] fix(cloud-agent-sdk): verify remote replay classification --- .../src/create-session.test.ts | 10 +- .../cloud-agent-sdk/src/create-session.ts | 92 ++++++++++++++++++- .../integration/user-connection-do.test.ts | 10 +- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/packages/cloud-agent-sdk/src/create-session.test.ts b/packages/cloud-agent-sdk/src/create-session.test.ts index cc7c2b992e..55797f1523 100644 --- a/packages/cloud-agent-sdk/src/create-session.test.ts +++ b/packages/cloud-agent-sdk/src/create-session.test.ts @@ -1,4 +1,5 @@ import { + classifyCreateSessionResult, createRemoteSessionOnConnection, createSessionResponseV1Schema, parseCreateSessionResponse, @@ -439,10 +440,9 @@ describe('createRemoteSessionOnConnection', () => { agent: 'code', }); - expect(parseCreateSessionResponse(replayed)).toEqual(parseCreateSessionResponse(live)); - expect(parseCreateSessionResponse(replayed)).toEqual({ - ok: true, - kiloSessionId: VALID_SESSION_ID, - }); + const liveOutcome = classifyCreateSessionResult({ status: 'fulfilled', value: live }); + const replayedOutcome = classifyCreateSessionResult({ status: 'fulfilled', value: replayed }); + expect(replayedOutcome).toEqual(liveOutcome); + expect(replayedOutcome).toEqual({ status: 'ready', sessionID: VALID_SESSION_ID }); }); }); diff --git a/packages/cloud-agent-sdk/src/create-session.ts b/packages/cloud-agent-sdk/src/create-session.ts index 5cf8ce4d9c..8ddaec9e1d 100644 --- a/packages/cloud-agent-sdk/src/create-session.ts +++ b/packages/cloud-agent-sdk/src/create-session.ts @@ -15,7 +15,11 @@ import { createSessionResponseV1Schema } from './schemas'; import type { CreateRemoteSessionInput } from './transport'; import type { KiloSessionId } from './types'; -import { CommandDeliveredError, type UserWebConnection } from './user-web-connection'; +import { + CommandDeliveredError, + UserWebCommandError, + type UserWebConnection, +} from './user-web-connection'; export { createSessionResponseV1Schema } from './schemas'; export type { CreateSessionResponseV1 } from './schemas'; @@ -131,3 +135,89 @@ export async function createRemoteSessionOnConnection( throw error; } } + +/** + * Exact-match literal for the relay's "instance disconnected" string + * (`UserConnectionDO`). Special-cased to `retryable` because semantically the + * instance disconnected, which is the same recovery path as a transport + * failure. + */ +export const SESSION_OWNER_NOT_FOUND_LITERAL = 'Session owner not found'; + +export type CreateSessionOutcome = + | { status: 'ready'; sessionID: KiloSessionId } + | { status: 'retryable'; reason: string; cause: unknown } + | { status: 'nonRetryable'; reason: string; cause: unknown }; + +/** + * Classify the resolved-or-rejected outcome of `createRemoteSessionOnConnection` + * into the spawn flow's state space: + * + * - `ready` — a fresh `KiloSessionId` was provisioned by the CLI + * - `retryable` — either a transport-level failure (timeout, destroyed + * connection, socket gone) OR the relay-emitted literal + * `'Session owner not found'` + * - `nonRetryable` — anything else: a malformed response envelope, a + * delivered CLI string error, or any structured + * `UserWebCommandError` + * + * A durable D8 replay of a terminal envelope carries exactly the live + * envelope's shape under the retry request's id, so a replayed envelope must + * classify identically to the original live delivery. + * + * The `cause` field preserves the original error for callers that want to + * surface or log it; `reason` is a short, user-safe string intended for UI. + */ +export function classifyCreateSessionResult( + result: PromiseSettledResult +): CreateSessionOutcome { + if (result.status === 'fulfilled') { + const parsed = parseCreateSessionResponse(result.value); + if (parsed.ok) { + return { status: 'ready', sessionID: parsed.kiloSessionId }; + } + return { + status: 'nonRetryable', + reason: 'unexpected response shape', + cause: result.value, + }; + } + + // result.status === 'rejected' + const cause: unknown = result.reason; + + // Structured relay error: keep `.code` available; the classifier still + // intentionally maps all such errors to `nonRetryable`. + if (cause instanceof UserWebCommandError) { + return { + status: 'nonRetryable', + reason: cause.message || cause.code, + cause, + }; + } + + // Delivered bare-string error: special-case the relay's vanished-connection + // literal to `retryable` (see `SESSION_OWNER_NOT_FOUND_LITERAL`). + if (cause instanceof CommandDeliveredError) { + if (cause.message === SESSION_OWNER_NOT_FOUND_LITERAL) { + return { + status: 'retryable', + reason: SESSION_OWNER_NOT_FOUND_LITERAL, + cause, + }; + } + return { + status: 'nonRetryable', + reason: cause.message, + cause, + }; + } + + // Anything else (plain `Error` from timeout / destroyed connection / + // socket gone) is a transport failure: retryable. + return { + status: 'retryable', + reason: cause instanceof Error ? cause.message : 'transport failure', + cause, + }; +} diff --git a/services/session-ingest/test/integration/user-connection-do.test.ts b/services/session-ingest/test/integration/user-connection-do.test.ts index 451894a182..742951b624 100644 --- a/services/session-ingest/test/integration/user-connection-do.test.ts +++ b/services/session-ingest/test/integration/user-connection-do.test.ts @@ -102,6 +102,10 @@ describe('UserConnectionDO integration', () => { const webWs1 = await connectWs(stub, `/web?connectionId=web-1`); const web1 = collectMessages(webWs1); + // Track every socket the test opens so the cleanup below closes each one + // deterministically, including the post-hibernation socket. + const sockets: WebSocket[] = [cliWs, webWs1]; + try { // Connection-scoped create_session with the extended wire identity. webWs1.send( @@ -180,6 +184,7 @@ describe('UserConnectionDO integration', () => { // terminal envelope without re-forwarding to the CLI. webWs1.close(1000, 'hibernation drop'); const webWs2 = await connectWs(stub, `/web?connectionId=web-2`); + sockets.push(webWs2); const web2 = collectMessages(webWs2); webWs2.send( @@ -207,8 +212,9 @@ describe('UserConnectionDO integration', () => { // The durable replay never re-forwards to the CLI. expect(cli.count(message => message.type === 'command')).toBe(1); } finally { - cliWs.close(1000, 'test complete'); - webWs1.close(1000, 'test complete'); + for (const ws of sockets) { + ws.close(1000, 'test complete'); + } } }); }); From 3360aaf77708531796b3aad75a5e105ec55969a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 10:21:57 +0200 Subject: [PATCH 13/56] fix(security): guard optional command ids --- .../security-agent/SecurityAgentContext.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/security-agent/SecurityAgentContext.tsx b/apps/web/src/components/security-agent/SecurityAgentContext.tsx index 9d2b59dcd7..a6c5ab5f64 100644 --- a/apps/web/src/components/security-agent/SecurityAgentContext.tsx +++ b/apps/web/src/components/security-agent/SecurityAgentContext.tsx @@ -751,7 +751,9 @@ function useSecurityAgentProviderValue( onSuccess: data => { dispatchProviderState({ type: 'set-github-error', error: null }); toast.success(securityAgentCommandAdmissionCopy.sync.successTitle); - trackCommand(data.commandId); + if (data.commandId) { + trackCommand(data.commandId); + } }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -773,7 +775,9 @@ function useSecurityAgentProviderValue( trpc.organizations.securityAgent.dismissFinding.mutationOptions({ onSuccess: data => { toast.success(securityAgentCommandAdmissionCopy.dismiss_finding.successTitle); - trackCommand(data.commandId); + if (data.commandId) { + trackCommand(data.commandId); + } }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -849,7 +853,9 @@ function useSecurityAgentProviderValue( ? 'Analysis restart queued' : securityAgentCommandAdmissionCopy.start_analysis.successTitle ); - trackCommand(data.commandId); + if (data.commandId) { + trackCommand(data.commandId); + } }, onError: (error, variables) => { const message = error instanceof Error ? error.message : String(error); @@ -950,7 +956,9 @@ function useSecurityAgentProviderValue( onSuccess: data => { dispatchProviderState({ type: 'set-github-error', error: null }); toast.success(securityAgentCommandAdmissionCopy.sync.successTitle); - trackCommand(data.commandId); + if (data.commandId) { + trackCommand(data.commandId); + } }, onError: error => { const message = error instanceof Error ? error.message : String(error); @@ -972,7 +980,9 @@ function useSecurityAgentProviderValue( trpc.securityAgent.dismissFinding.mutationOptions({ onSuccess: data => { toast.success(securityAgentCommandAdmissionCopy.dismiss_finding.successTitle); - trackCommand(data.commandId); + if (data.commandId) { + trackCommand(data.commandId); + } }, onError: error => { toast.error('Failed to dismiss finding', { description: error.message }); @@ -1158,12 +1168,24 @@ function useSecurityAgentProviderValue( if (isOrg && organizationId) { orgDismissMutate( { organizationId, findingId: finding.id, reason, comment }, - { onSuccess: data => trackCommand(data.commandId, onSuccess) } + { + onSuccess: data => { + if (data.commandId) { + trackCommand(data.commandId, onSuccess); + } + }, + } ); } else { personalDismissMutate( { findingId: finding.id, reason, comment }, - { onSuccess: data => trackCommand(data.commandId, onSuccess) } + { + onSuccess: data => { + if (data.commandId) { + trackCommand(data.commandId, onSuccess); + } + }, + } ); } }, From ec8f5a2465ce976f6f77462eadb8d10a6f1b6aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 10:50:48 +0200 Subject: [PATCH 14/56] fix(analytics): await flush and key security dismissals --- .../lib/hooks/use-security-findings.test.ts | 253 ++++++++++++++++++ .../src/lib/hooks/use-security-findings.ts | 51 +++- .../src/lib/analytics-outbox/dispatch.test.ts | 42 +++ apps/web/src/lib/analytics-outbox/dispatch.ts | 22 +- apps/web/src/lib/posthog.ts | 12 + 5 files changed, 363 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-security-findings.test.ts diff --git a/apps/mobile/src/lib/hooks/use-security-findings.test.ts b/apps/mobile/src/lib/hooks/use-security-findings.test.ts new file mode 100644 index 0000000000..8e399c37f6 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-security-findings.test.ts @@ -0,0 +1,253 @@ +// P1-A-08e wiring tests for `useDismissSecurityFinding`. +// +// The dismiss screen owns the inline error copy; these tests assert the HOOK +// WIRING: the `mutationFn` delegates to the matching +// `trpcClient.(organizations.)securityAgent.dismissFinding.mutate`, the +// hoisted operation key is merged into the input, and the key rotation policy +// (real `isSecuritySyncRetryable` — the dismiss and sync procedures share the +// same security ledger) runs inside `mutationFn`. Only +// `useHoistedOperationKey` is mocked (it holds React ref state that needs a +// mounted renderer). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type * as PrOperationLedgerModule from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + dismissFindingIntentFingerprint, + useDismissSecurityFinding, +} from './use-security-findings'; + +const hoistedKeys = vi.hoisted(() => ({ + getKey: vi.fn(() => 'hoisted-op-key'), + rotateKey: vi.fn(), +})); + +const trackCommandMock = vi.hoisted(() => vi.fn()); +const toastErrorMock = vi.fn(); + +vi.mock('expo-crypto', () => ({ + randomUUID: () => 'not-used', +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useHoistedOperationKey: () => hoistedKeys }; +}); + +vi.mock('@kilocode/app-shared/security-agent', () => ({ + isPersonalSecurityScope: (scope: string) => scope === 'personal', + getNextSecurityFindingsOffset: () => undefined, + getRemediationUnavailableCopy: () => undefined, + isActiveRemediationStatus: () => false, +})); + +vi.mock('@/lib/hooks/use-security-agent-commands', () => ({ + trackSecurityAgentCommand: trackCommandMock, +})); + +vi.mock('@/lib/a11y/announcing-toast', () => ({ + announcingToast: { + error: (msg: string) => toastErrorMock(msg), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock('sonner-native', () => ({ + toast: { error: (msg: string) => toastErrorMock(msg) }, +})); + +type MutationOptions = { + mutationFn?: (vars: unknown) => Promise; + onError?: (error: unknown) => void; + onSuccess?: (result: unknown, vars: unknown) => void; + onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; + onMutate?: (vars: unknown) => unknown; +}; + +let lastCapturedOptions: MutationOptions | null = null; +const personalDismissMutateMock = vi.fn(); +const orgDismissMutateMock = vi.fn(); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (opts: MutationOptions) => { + lastCapturedOptions = opts; + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isError: false, error: null }; + }, + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + cancelQueries: vi.fn(), + }), + useInfiniteQuery: vi.fn(), + useQuery: vi.fn(), +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + securityAgent: { + getFinding: { queryKey: () => ['securityAgent', 'getFinding'] }, + getAnalysis: { queryKey: () => ['securityAgent', 'getAnalysis'] }, + listFindings: { queryKey: () => ['securityAgent', 'listFindings'] }, + getDashboardStats: { queryKey: () => ['securityAgent', 'getDashboardStats'] }, + }, + organizations: { + securityAgent: { + getFinding: { queryKey: () => ['securityAgent', 'getFinding'] }, + getAnalysis: { queryKey: () => ['securityAgent', 'getAnalysis'] }, + listFindings: { queryKey: () => ['securityAgent', 'listFindings'] }, + getDashboardStats: { queryKey: () => ['securityAgent', 'getDashboardStats'] }, + }, + }, + }), + trpcClient: { + securityAgent: { + dismissFinding: { mutate: (vars: unknown) => personalDismissMutateMock(vars) }, + }, + organizations: { + securityAgent: { + dismissFinding: { mutate: (vars: unknown) => orgDismissMutateMock(vars) }, + }, + }, + }, +})); + +const ORG_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const FINDING_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const DISMISS_VARS = { + findingId: FINDING_ID, + reason: 'not_used', + comment: 'retired code', +} as const; + +describe('useDismissSecurityFinding (P1-A-08e wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + personalDismissMutateMock.mockReset(); + orgDismissMutateMock.mockReset(); + toastErrorMock.mockReset(); + trackCommandMock.mockClear(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mounts a useMutation with a custom mutationFn', () => { + useDismissSecurityFinding('personal'); + expect(lastCapturedOptions?.mutationFn).toBeDefined(); + }); + + it('delegates a personal dismissal to securityAgent.dismissFinding.mutate with the hoisted key', async () => { + const result = { success: true, accepted: true, commandId: 'cmd-1' }; + personalDismissMutateMock.mockResolvedValueOnce(result); + useDismissSecurityFinding('personal'); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).resolves.toEqual(result); + + expect(hoistedKeys.getKey).toHaveBeenCalled(); + expect(personalDismissMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + findingId: FINDING_ID, + reason: 'not_used', + comment: 'retired code', + operationKey: 'hoisted-op-key', + }) + ); + }); + + it('delegates an org dismissal to organizations.securityAgent.dismissFinding.mutate with the key', async () => { + orgDismissMutateMock.mockResolvedValueOnce({ success: true, commandId: 'cmd-2' }); + useDismissSecurityFinding(ORG_ID); + + await lastCapturedOptions?.mutationFn?.(DISMISS_VARS); + + expect(orgDismissMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + findingId: FINDING_ID, + operationKey: 'hoisted-op-key', + }) + ); + }); + + it('regenerates the key after a successful dismissal (fresh intent next)', async () => { + personalDismissMutateMock.mockResolvedValueOnce({ success: true, commandId: 'cmd-1' }); + useDismissSecurityFinding('personal'); + + await lastCapturedOptions?.mutationFn?.(DISMISS_VARS); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT (same-key retry reconciles)', async () => { + personalDismissMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useDismissSecurityFinding('personal'); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ + message: 'operation_in_progress', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('keeps the key on a retryable network failure (the ledger owns the retry)', async () => { + personalDismissMutateMock.mockRejectedValueOnce(new Error('Network request failed')); + useDismissSecurityFinding('personal'); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ + message: 'Network request failed', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Invalid dismissal reason'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + personalDismissMutateMock.mockRejectedValueOnce(badRequest); + useDismissSecurityFinding('personal'); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ + message: 'Invalid dismissal reason', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('regenerates the key on the persistence-failure marker even though it is CONFLICT', async () => { + personalDismissMutateMock.mockRejectedValueOnce( + new Error('We could not record this action. Please try again later.') + ); + useDismissSecurityFinding('personal'); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ + message: 'We could not record this action. Please try again later.', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('onSuccess tracks the accepted command', () => { + useDismissSecurityFinding('personal'); + lastCapturedOptions?.onSuccess?.({ success: true, commandId: 'cmd-9' }, DISMISS_VARS); + expect(trackCommandMock).toHaveBeenCalled(); + }); +}); + +describe('dismissFindingIntentFingerprint (P1-A-08e changed-input)', () => { + it('stays stable for a retry of the same scope+finding and rotates when any intent input changes', () => { + const original = dismissFindingIntentFingerprint('personal', DISMISS_VARS); + expect(dismissFindingIntentFingerprint('personal', DISMISS_VARS)).toBe(original); + + expect( + dismissFindingIntentFingerprint('personal', { ...DISMISS_VARS, findingId: FINDING_ID }) + ).toBe(original); + expect( + dismissFindingIntentFingerprint('personal', { ...DISMISS_VARS, comment: 'edited' }) + ).not.toBe(original); + expect( + dismissFindingIntentFingerprint('personal', { ...DISMISS_VARS, reason: 'inaccurate' }) + ).not.toBe(original); + expect(dismissFindingIntentFingerprint(ORG_ID, DISMISS_VARS)).not.toBe(original); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index b832485b1f..2128314617 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -9,6 +9,8 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tansta import { toast } from 'sonner-native'; import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands'; +import { isSecuritySyncRetryable } from '@/lib/hooks/use-security-agent-mutations'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { type SecurityAnalysis } from '@/lib/security-agent'; import { trpcClient, useTRPC } from '@/lib/trpc'; @@ -95,17 +97,50 @@ export function useSecurityAnalysis(scope: string, findingId: string) { // No hook-level onError toast: dismiss-finding-screen.tsx is the sole caller // and is a form sheet that stays open on failure — it renders // `dismissFinding.isError` inline above the confirm button instead (P2). + +/** + * Deterministic intent fingerprint for a finding dismissal (P1-A-08e). A + * retry of the SAME scope+finding+reason+comment reuses the hoisted key; a + * form edit (or a scope change) rotates it so the ledger treats the submit as + * a fresh intent instead of replaying the previous one's canonical result. + */ +export function dismissFindingIntentFingerprint( + scope: string, + vars: Parameters[0] +): string { + return JSON.stringify({ + resource: [scope], + findingId: vars.findingId, + reason: vars.reason, + comment: vars.comment, + }); +} + export function useDismissSecurityFinding(scope: string) { const queryClient = useQueryClient(); + const { getKey, rotateKey } = useHoistedOperationKey(); return useMutation({ - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule - mutationFn: (vars: Parameters[0]) => - isPersonalSecurityScope(scope) - ? trpcClient.securityAgent.dismissFinding.mutate(vars) - : trpcClient.organizations.securityAgent.dismissFinding.mutate({ - organizationId: scope, - ...vars, - }), + mutationFn: async ( + vars: Parameters[0] + ) => { + const operationKey = getKey(dismissFindingIntentFingerprint(scope, vars)); + try { + const result = isPersonalSecurityScope(scope) + ? await trpcClient.securityAgent.dismissFinding.mutate({ ...vars, operationKey }) + : await trpcClient.organizations.securityAgent.dismissFinding.mutate({ + organizationId: scope, + ...vars, + operationKey, + }); + rotateKey(); + return result; + } catch (error) { + if (!isSecuritySyncRetryable(error)) { + rotateKey(); + } + throw error; + } + }, onSuccess: result => { if (result.commandId) { trackSecurityAgentCommand(queryClient, scope, result.commandId); diff --git a/apps/web/src/lib/analytics-outbox/dispatch.test.ts b/apps/web/src/lib/analytics-outbox/dispatch.test.ts index 7a8656cfe2..03536e9f7e 100644 --- a/apps/web/src/lib/analytics-outbox/dispatch.test.ts +++ b/apps/web/src/lib/analytics-outbox/dispatch.test.ts @@ -20,10 +20,12 @@ import { import type { AnalyticsEventOutboxRow } from '@kilocode/db/schema'; const mockCapture = jest.fn(); +const mockFlushPostHog = jest.fn(); jest.mock('@/lib/posthog', () => ({ __esModule: true, default: jest.fn(() => ({ capture: mockCapture })), + flushPostHog: (...args: unknown[]) => mockFlushPostHog(...args), })); jest.mock('@/lib/drizzle', () => ({ @@ -81,6 +83,8 @@ describe('dispatchQueuedAnalyticsEvents', () => { beforeEach(() => { jest.clearAllMocks(); mockCapture.mockReset(); + mockFlushPostHog.mockReset(); + mockFlushPostHog.mockResolvedValue(undefined); mockReclaimStaleSendingEvents.mockResolvedValue([]); mockClaimDueOutboxEvents.mockResolvedValue([]); mockPurgeExpired.mockResolvedValue(emptyPurge); @@ -158,6 +162,44 @@ describe('dispatchQueuedAnalyticsEvents', () => { expect(summary.delivered).toBe(0); }); + it('waits for the async flush before marking the event delivered', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockMarkOutboxDelivered.mockResolvedValue(row); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockFlushPostHog).toHaveBeenCalledTimes(1); + expect(mockFlushPostHog.mock.invocationCallOrder[0]).toBeLessThan( + mockMarkOutboxDelivered.mock.invocationCallOrder[0] + ); + expect(summary.delivered).toBe(1); + }); + + it('backs a rejected async flush off for retry with the error recorded', async () => { + const row = makeRow(); + mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); + mockFlushPostHog.mockRejectedValue(new Error('flush timed out')); + mockMarkOutboxRetry.mockResolvedValue({ + outcome: 'retried', + row: { ...row, status: 'pending', attempts: 1 }, + }); + + const summary = await dispatchQueuedAnalyticsEvents(); + + expect(mockCapture).toHaveBeenCalledTimes(1); + expect(mockFlushPostHog).toHaveBeenCalledTimes(1); + expect(mockMarkOutboxRetry).toHaveBeenCalledWith(expect.anything(), { + eventId: row.id, + claimedAt: row.claimed_at, + error: 'flush timed out', + }); + expect(mockMarkOutboxDelivered).not.toHaveBeenCalled(); + expect(summary.retried).toBe(1); + expect(summary.delivered).toBe(0); + }); + it('fails a claimed event terminally when the retry cap is reached', async () => { const row = makeRow(); mockClaimDueOutboxEvents.mockResolvedValueOnce([row]); diff --git a/apps/web/src/lib/analytics-outbox/dispatch.ts b/apps/web/src/lib/analytics-outbox/dispatch.ts index c2bcfa0ef2..a6d21f84d7 100644 --- a/apps/web/src/lib/analytics-outbox/dispatch.ts +++ b/apps/web/src/lib/analytics-outbox/dispatch.ts @@ -10,14 +10,15 @@ * no-op, so rows still advance to `delivered`; the state machine is * environment-independent. Each send passes the deterministic `event_uuid` as * the PostHog event UUID (`posthog-node` 5.10.4 supports `EventMessage.uuid`), - * which PostHog uses to dedupe replayed at-least-once deliveries. A - * synchronous capture throw is a failed send and drives the DB-side backoff - * retry or terminal failure. + * which PostHog uses to dedupe replayed at-least-once deliveries. The send is + * awaited through the client flush so a cron pass cannot mark an event + * delivered before PostHog has it; a capture throw or a rejected flush is a + * failed send and drives the DB-side backoff retry or terminal failure. */ import 'server-only'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; -import PostHogClient from '@/lib/posthog'; +import PostHogClient, { flushPostHog } from '@/lib/posthog'; import { sentryLogger } from '@/lib/utils.server'; import { claimDueOutboxEvents, @@ -135,7 +136,7 @@ async function dispatchOutboxEvent( } try { - sendToPostHog(row); + await sendToPostHog(row); } catch (error) { const message = errorMessage(error); logError('Analytics outbox send failed', { @@ -173,17 +174,20 @@ async function dispatchOutboxEvent( } /** - * Sends one event to PostHog. The deterministic `event_uuid` goes in the - * PostHog event UUID field; if the installed client ever dropped that field, - * the catalog fallback carries it as an `event_uuid` property instead. + * Sends one event to PostHog and waits for the client flush so the send is + * durable before the row is marked delivered. The deterministic `event_uuid` + * goes in the PostHog event UUID field; if the installed client ever dropped + * that field, the catalog fallback carries it as an `event_uuid` property + * instead. */ -function sendToPostHog(row: AnalyticsEventOutboxRow): void { +async function sendToPostHog(row: AnalyticsEventOutboxRow): Promise { PostHogClient().capture({ distinctId: row.distinct_id, event: row.event_name, properties: row.properties, uuid: row.event_uuid, }); + await flushPostHog(); } function outboxLogFields(row: AnalyticsEventOutboxRow): Record { diff --git a/apps/web/src/lib/posthog.ts b/apps/web/src/lib/posthog.ts index 45edcd3fea..9699a19cca 100644 --- a/apps/web/src/lib/posthog.ts +++ b/apps/web/src/lib/posthog.ts @@ -39,6 +39,18 @@ export default function PostHogClient(): Pick< return instance; } +/** + * Flushes any buffered PostHog events and resolves when the send completes. + * Outside production (where the client is a disabled no-op and no shared + * instance exists) this is a successful no-op, so callers can always await it. + */ +export async function flushPostHog(): Promise { + if (!instance) { + return; + } + await instance.flush(); +} + export async function shutdownPosthog(): Promise { if (instance) { await instance.shutdown(); From dd4dc172e63d9b9e50df8c7db161ce85c6c35b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 11:32:46 +0200 Subject: [PATCH 15/56] feat(mobile): stabilize session creation operation keys --- .../agents/mobile-session-manager.test.ts | 186 ++++++- .../agents/mobile-session-manager.ts | 90 +++- .../agents/use-continue-session.test.ts | 468 ++++++++++++++++++ .../components/agents/use-continue-session.ts | 74 ++- .../agents/use-new-session-creator.test.ts | 223 +++++++++ .../agents/use-new-session-creator.ts | 31 ++ .../agents/use-remote-spawn-dispatch.test.ts | 166 ++++++- .../agents/use-remote-spawn-dispatch.ts | 30 +- .../share/share-gate-sheet.mounted.test.tsx | 333 +++++++++++++ .../src/components/share/share-gate-sheet.tsx | 29 +- .../hooks/remote-instance-spawn-classifier.ts | 36 +- .../lib/hooks/use-remote-instance-spawn.ts | 19 +- 12 files changed, 1619 insertions(+), 66 deletions(-) create mode 100644 apps/mobile/src/components/agents/use-continue-session.test.ts create mode 100644 apps/mobile/src/components/agents/use-new-session-creator.test.ts create mode 100644 apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/mobile-session-manager.test.ts b/apps/mobile/src/components/agents/mobile-session-manager.test.ts index 9c629066db..029bb9575a 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.test.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.test.ts @@ -1,4 +1,5 @@ /* eslint-disable require-await, @typescript-eslint/require-await -- injectable query/sleep fakes settle without await */ +/* eslint-disable max-lines -- the manager suite pins key rotation, retry cadence, and attachment mints in one file. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type AgentAttachmentSubmissionPayload } from '@/lib/agent-attachments/agent-attachment-types'; @@ -11,6 +12,15 @@ import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn(), })); +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `op-key-${n}`; + }, + }; +}); vi.mock('sonner-native', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })); @@ -41,18 +51,32 @@ vi.mock('@/components/agents/tool-card-image-cache', () => ({ })); const mutate = vi.fn(); +const prepareSessionMutate = vi.fn(); vi.mock('@/lib/trpc', () => ({ trpcClient: { cloudAgentNext: { getAttachmentDownloadUrl: { mutate }, + prepareSession: { mutate: prepareSessionMutate }, + }, + organizations: { + cloudAgentNext: { prepareSession: { mutate: prepareSessionMutate } }, }, }, })); const { buildRemoteAttachmentParts } = await import('@/components/agents/mobile-session-manager-helpers'); -const { fetchSessionWithNotFoundRetry, readFetchSessionErrorCode } = - await import('@/components/agents/mobile-session-manager'); +const { + createMobileAgentSessionManager, + fetchSessionWithNotFoundRetry, + isCloudPrepareRetryableError, + readFetchSessionErrorCode, +} = await import('@/components/agents/mobile-session-manager'); +const { createSessionManager: createSessionManagerReal } = + await import('@kilocode/cloud-agent-sdk'); +// The module is mocked with `createSessionManager: vi.fn()` above; recover +// the mock instance typing so `.mockClear()` / `.mock.calls` typecheck. +const createSessionManagerMock = vi.mocked(createSessionManagerReal); const SESSION_ID = 'ses_test_session_id_0000000001' as KiloSessionId; @@ -62,6 +86,18 @@ function notFoundError(): Error { return error; } +function withCode(code: string, message: string): Error { + return Object.assign(new Error(message), { data: { code } }); +} + +function creationInProgressError(): Error { + return Object.assign(new Error('creation_in_progress'), { data: { code: 'CONFLICT' } }); +} + +function badRequestError(): Error { + return Object.assign(new Error('session_creation_failed'), { data: { code: 'BAD_REQUEST' } }); +} + describe('buildRemoteAttachmentParts', () => { beforeEach(() => { mutate.mockReset(); @@ -189,6 +225,152 @@ describe('readFetchSessionErrorCode', () => { }); }); +describe('isCloudPrepareRetryableError', () => { + it('keeps the key for creation_in_progress (CONFLICT)', () => { + expect(isCloudPrepareRetryableError(withCode('CONFLICT', 'creation_in_progress'))).toBe(true); + }); + + it('keeps the key for a network error with no tRPC code', () => { + expect(isCloudPrepareRetryableError(new Error('Network request failed'))).toBe(true); + }); + + it('keeps the key for transient 5xx-class and rate-limit codes', () => { + for (const code of [ + 'INTERNAL_SERVER_ERROR', + 'BAD_GATEWAY', + 'SERVICE_UNAVAILABLE', + 'GATEWAY_TIMEOUT', + 'TIMEOUT', + 'TOO_MANY_REQUESTS', + ]) { + expect(isCloudPrepareRetryableError(withCode(code, 'boom'))).toBe(true); + } + }); + + it('rotates the key on typed terminal rejections', () => { + for (const code of [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'PAYMENT_REQUIRED', + 'PRECONDITION_FAILED', + ]) { + expect(isCloudPrepareRetryableError(withCode(code, 'nope'))).toBe(false); + } + }); + + it('rotates the key on a CONFLICT with any other message', () => { + expect(isCloudPrepareRetryableError(withCode('CONFLICT', 'something else'))).toBe(false); + }); +}); + +describe('createMobileAgentSessionManager prepare operationKey', () => { + const PREPARE_INPUT = { + prompt: 'continue this', + mode: 'code', + model: 'kilo-auto/efficient', + githubRepo: 'owner/repo', + initialMessageId: 'msg-1', + }; + + function createPrepare(): { + prepare: (input: Record) => Promise<{ + cloudAgentSessionId: string; + kiloSessionId: string; + }>; + } { + createSessionManagerMock.mockClear(); + createMobileAgentSessionManager({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- store is never read with the SDK mocked + store: {} as never, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- connection is never read with the SDK mocked + userWebConnection: {} as never, + organizationId: undefined, + }); + const config = createSessionManagerMock.mock.calls[0]?.[0]; + if (!config) { + throw new Error('createSessionManager was not called'); + } + return config as unknown as { + prepare: (input: Record) => Promise<{ + cloudAgentSessionId: string; + kiloSessionId: string; + }>; + }; + } + + beforeEach(() => { + prepareSessionMutate.mockReset(); + }); + + it('attaches a stable operationKey when autoInitiate is true and keeps it across retryable failures', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()) + .mockResolvedValue({ cloudAgentSessionId: 'c-1', kiloSessionId: 'k-1' }); + const config = createPrepare(); + const input = { ...PREPARE_INPUT, autoInitiate: true }; + + await expect(config.prepare(input)).rejects.toBeDefined(); + await expect(config.prepare(input)).rejects.toBeDefined(); + await config.prepare(input); + + const keys = prepareSessionMutate.mock.calls.map( + call => (call[0] as { operationKey?: string }).operationKey + ); + expect(keys).toEqual([expect.any(String), keys[0], keys[0]]); + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + autoInitiate: true, + operationKey: expect.any(String), + }); + }); + + it('rotates the operationKey after a successful prepare', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockResolvedValue({ cloudAgentSessionId: 'c-1', kiloSessionId: 'k-1' }); + const config = createPrepare(); + const input = { ...PREPARE_INPUT, autoInitiate: true }; + + await expect(config.prepare(input)).rejects.toBeDefined(); + await config.prepare(input); + await config.prepare(input); + + const keys = prepareSessionMutate.mock.calls.map( + call => (call[0] as { operationKey?: string }).operationKey + ); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).not.toBe(keys[0]); + }); + + it('rotates the operationKey after a typed non-retryable rejection', async () => { + prepareSessionMutate + .mockRejectedValueOnce(badRequestError()) + .mockResolvedValue({ cloudAgentSessionId: 'c-1', kiloSessionId: 'k-1' }); + const config = createPrepare(); + const input = { ...PREPARE_INPUT, autoInitiate: true }; + + await expect(config.prepare(input)).rejects.toBeDefined(); + await config.prepare(input); + + const keys = prepareSessionMutate.mock.calls.map( + call => (call[0] as { operationKey?: string }).operationKey + ); + expect(keys[1]).not.toBe(keys[0]); + }); + + it('never attaches an operationKey when autoInitiate is absent', async () => { + prepareSessionMutate.mockResolvedValue({ cloudAgentSessionId: 'c-1', kiloSessionId: 'k-1' }); + const config = createPrepare(); + + await config.prepare(PREPARE_INPUT); + + expect(prepareSessionMutate.mock.calls[0]?.[0]).not.toHaveProperty('operationKey'); + }); +}); + describe('fetchSessionWithNotFoundRetry', () => { // Production return type is SessionWithRuntimeState; tests inject a minimal // stand-in via `query` and only assert retry/cadence behavior. diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 613c1914b8..5153e11f87 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- fetchSession NOT_FOUND retry helpers stay with the manager (M1). */ +import * as Crypto from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; import { toast } from 'sonner-native'; import { @@ -65,6 +66,46 @@ export function readFetchSessionErrorCode(error: unknown): string | undefined { return undefined; } +/** + * tRPC codes that are transient enough to keep the same cloud-prepare + * `operationKey` across a retry. The operation ledger admits/reconciles a + * same-key retry instead of re-executing, so these must never rotate the + * key. Everything else with a typed tRPC code (BAD_REQUEST, FORBIDDEN, + * UNAUTHORIZED, NOT_FOUND, PAYMENT_REQUIRED, PRECONDITION_FAILED, ...) is a + * typed terminal rejection: the key must rotate so the next submit is a + * fresh intent and cannot replay the settled failure. + */ +const CLOUD_PREPARE_TRANSIENT_CODES = new Set([ + 'INTERNAL_SERVER_ERROR', + 'BAD_GATEWAY', + 'SERVICE_UNAVAILABLE', + 'GATEWAY_TIMEOUT', + 'TIMEOUT', + 'TOO_MANY_REQUESTS', +]); + +/** Stable message the ledger returns on a same-key in-flight duplicate (plan P1-A-08b). */ +export const CLOUD_PREPARE_IN_PROGRESS_MESSAGE = 'creation_in_progress'; + +/** + * True when a `prepareSession` failure may be retried with the SAME + * `operationKey`. Keeps the key across `creation_in_progress` and transient + * transport/5xx failures; a typed terminal rejection (or an error with no + * code, which is treated as transport) is the only rotation signal. + */ +export function isCloudPrepareRetryableError(error: unknown): boolean { + const code = readFetchSessionErrorCode(error); + if (code === undefined) { + // Transport/network failure — retrying under the same key is safe and + // lets the ledger reconcile an ambiguous prior attempt. + return true; + } + if (code === 'CONFLICT') { + return error instanceof Error && error.message === CLOUD_PREPARE_IN_PROGRESS_MESSAGE; + } + return CLOUD_PREPARE_TRANSIENT_CODES.has(code); +} + /* eslint-disable @typescript-eslint/promise-function-async, require-await -- thin tRPC passthrough */ async function defaultFetchSessionQuery( sessionId: KiloSessionId @@ -137,6 +178,13 @@ export function createMobileAgentSessionManager({ userWebConnection, organizationId, }: Readonly): SessionManager { + // One per-intent operation key for ledger-guarded cloud creates. Attached + // only when the prepared input carries `autoInitiate: true`; the SDK's + // `PrepareInput` never sets it today, so this branch stays dormant (the + // split prepare/initiate flow is a recorded plan exclusion) and preserves + // legacy behavior until a future caller supplies it. + let cloudPrepareOperationKey: string | undefined = undefined; + return createSessionManager({ store, websocketBaseUrl: CLOUD_AGENT_WS_URL, @@ -309,24 +357,46 @@ export function createMobileAgentSessionManager({ }, prepare: async input => { const prepared = await withCloudAgentDiagnostics('prepare', organizationId, async () => { + const effectiveAutoInitiate = (input as { autoInitiate?: boolean }).autoInitiate === true; + let usedOperationKey = false; + if (effectiveAutoInitiate) { + cloudPrepareOperationKey ??= Crypto.randomUUID(); + usedOperationKey = true; + } const castInput = { ...input, + ...(usedOperationKey && cloudPrepareOperationKey !== undefined + ? { operationKey: cloudPrepareOperationKey } + : {}), initialPayload: input.initialPayload ? normalizeTransportPayload(input.initialPayload) : undefined, mode: input.mode as AgentMode, }; - const result = organizationId - ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate( - { ...castInput, organizationId }, - skipBatchOptions - ) - : await trpcClient.cloudAgentNext.prepareSession.mutate(castInput, skipBatchOptions); - return { - cloudAgentSessionId: result.cloudAgentSessionId as CloudAgentSessionId, - kiloSessionId: result.kiloSessionId as KiloSessionId, - }; + try { + const result = organizationId + ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate( + { ...castInput, organizationId }, + skipBatchOptions + ) + : await trpcClient.cloudAgentNext.prepareSession.mutate(castInput, skipBatchOptions); + return { + cloudAgentSessionId: result.cloudAgentSessionId as CloudAgentSessionId, + kiloSessionId: result.kiloSessionId as KiloSessionId, + }; + } catch (error) { + // A typed terminal rejection ends the intent: the next submit is a + // fresh intent with a fresh key. Retryable failures (transport and + // `creation_in_progress`) keep the key so the ledger dedupes or + // reconciles the retry instead of spawning a second session. + if (usedOperationKey && !isCloudPrepareRetryableError(error)) { + cloudPrepareOperationKey = undefined; + } + throw error; + } }); + // Success: the intent settled; the next submit is a fresh intent. + cloudPrepareOperationKey = undefined; return prepared; }, initiate: async input => { diff --git a/apps/mobile/src/components/agents/use-continue-session.test.ts b/apps/mobile/src/components/agents/use-continue-session.test.ts new file mode 100644 index 0000000000..fd22fdfd19 --- /dev/null +++ b/apps/mobile/src/components/agents/use-continue-session.test.ts @@ -0,0 +1,468 @@ +/* eslint-disable import/first -- mocks must be defined before the module under test is imported */ +/* eslint-disable max-lines -- the suite pins both key families (cloud prepare + remote spawn) through one fake-dispatcher runner. */ +import * as React from 'react'; +import { atom, type createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; + +type JotaiStore = ReturnType; + +// P1-A-08b: `useContinueSession` keeps TWO hoisted operation keys — one per +// destination family. Cloud prepares and remote spawns are different +// intents, so they never share a key; each is kept across retryable +// failures (so the ledger/relay dedupes the same-key retry) and rotated on +// success or a typed terminal rejection. This suite pins both families +// through a fake React dispatcher, mocking only the outside world. +// +// Destination resolution is deliberately mocked: this suite tests KEY +// WIRING, not `resolveContinuationDestinations` (which has its own module). + +const prepareSessionMutate = vi.hoisted(() => vi.fn()); +const remoteSpawnMock = vi.hoisted(() => + vi.fn( + // eslint-disable-next-line require-await, typescript-eslint/require-await -- mock returns a settled outcome without awaiting + async ( + _connectionId: string, + _opts?: unknown, + _options?: unknown + ): Promise => ({ + status: 'retryable', + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }) + ) +); +const routerPush = vi.hoisted(() => vi.fn()); +const queryClientFetchQuery = vi.hoisted(() => vi.fn()); +const showActionSheetWithOptions = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); +// Destination list handed back by the mocked resolver; each test sets the +// single destination the continue flow should execute against. +const destinationsRef = vi.hoisted(() => ({ value: [] as unknown[] })); +// Lazy jotai store: `useStore()` returns one store for the whole suite and +// `store.get(manager.atoms.*)` reads the atoms' seeded initial values. +const storeRef = vi.hoisted(() => ({ + current: undefined as JotaiStore | undefined, +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: routerPush }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ fetchQuery: queryClientFetchQuery }), +})); +vi.mock('jotai', async importOriginal => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- namespace type for the real jotai module under vi.mock + const actual = await importOriginal(); + return { + ...actual, + useStore: () => { + storeRef.current ??= actual.createStore(); + return storeRef.current; + }, + }; +}); +vi.mock('@expo/react-native-action-sheet', () => ({ + useActionSheet: () => ({ showActionSheetWithOptions }), +})); +vi.mock('@kilocode/cloud-agent-sdk/message-id', () => ({ + generateMessageId: () => 'msg-1', +})); +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ + toast: { error: toastError }, +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + SESSION_CREATED_EVENT: 'session_created', +})); +vi.mock('@/lib/agent-session-cache', () => ({ + invalidateAgentSessionQueries: vi.fn(), +})); +vi.mock('@/lib/share-payload', () => ({ + putSharePayload: () => 'share-1', +})); +vi.mock('@/lib/share-navigation', () => ({ + appendShareParams: (base: string) => base, +})); +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + cloudAgentNext: { prepareSession: { mutate: prepareSessionMutate } }, + organizations: { cloudAgentNext: { prepareSession: { mutate: prepareSessionMutate } } }, + }, + useTRPC: () => ({ + cloudAgentNext: { + listGitHubRepositories: { queryOptions: () => ({ queryKey: ['repositories'] }) }, + }, + organizations: { + cloudAgentNext: { + listGitHubRepositories: { queryOptions: () => ({ queryKey: ['repositories'] }) }, + }, + }, + activeSessions: { listInstances: { queryOptions: () => ({ queryKey: ['instances'] }) } }, + }), +})); +// The real classifier lives in mobile-session-manager (covered by its own +// suite); this test only needs the retryable/non-retryable split. +vi.mock('@/components/agents/mobile-session-manager', () => ({ + isCloudPrepareRetryableError: (error: unknown) => { + const record = error as { data?: { code?: string }; message?: string }; + return record.data?.code === 'CONFLICT' && record.message === 'creation_in_progress'; + }, +})); +vi.mock('@/components/agents/mode-options', () => ({ + normalizeAgentMode: (mode: string | null | undefined) => + mode === 'code' || + mode === 'plan' || + mode === 'debug' || + mode === 'orchestrator' || + mode === 'ask' + ? mode + : 'code', +})); +vi.mock('@/components/agents/new-session-prefill', () => ({ + appendNewSessionPrefill: (base: string) => base, + buildContinuePrefillParams: () => ({}), +})); +// The real continuation-seed module pulls in mode-options -> lucide-react-native +// (RN tree); this suite pins key wiring, so the resolver is a test hook. +vi.mock('@/components/agents/continuation-seed', () => ({ + buildContinuationSeed: () => 'seed-text', + resolveContinueRemoteModel: (model: string, variant: string) => ({ model, variant }), + resolveContinuationDestinations: () => destinationsRef.value, +})); +// Keep the real input builder; only stub the RN-touching spawn hook. The +// builder is imported from the pure classifier module — the hook module +// itself pulls in react-native via `useUserWebConnection` and cannot load +// under the plain Node vitest environment (see the classifier's header). +vi.mock('@/lib/hooks/use-remote-instance-spawn', () => ({ + buildCreateRemoteSessionInput, + useRemoteInstanceSpawn: () => ({ spawn: remoteSpawnMock }), +})); +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `op-key-${n}`; + }, + }; +}); + +// The pure input builder must be imported BEFORE the module under test: the +// mocked `use-remote-instance-spawn` factory reads this binding when +// `use-continue-session.ts` loads it. +import { + buildCreateRemoteSessionInput, + type CreateSessionOutcome, +} from '@/lib/hooks/remote-instance-spawn-classifier'; +import { useContinueSession } from './use-continue-session'; + +function creationInProgressError(): Error { + return Object.assign(new Error('creation_in_progress'), { data: { code: 'CONFLICT' } }); +} + +function badRequestError(): Error { + return Object.assign(new Error('session_creation_failed'), { data: { code: 'BAD_REQUEST' } }); +} + +function retryableOutcome() { + return { + status: 'retryable' as const, + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }; +} + +function readyOutcome(): CreateSessionOutcome { + return { + status: 'ready', + sessionID: 'ses_12345678901234567890123456' as KiloSessionId, + }; +} + +function nonRetryableOutcome() { + return { + status: 'nonRetryable' as const, + reason: 'CLI_UPGRADE_REQUIRED', + cause: new Error('CLI_UPGRADE_REQUIRED'), + }; +} + +// Fake manager over real jotai atoms; the store's `get` reads the seeded +// initial values, so `hasOlderMessages` stays false and the drain loop is a +// no-op while `messagesList` is non-empty for the seed builder. +const hasOlderMessagesAtom = atom(false); +const messagesListAtom = atom([ + { info: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }] }, +]); +const manager = { + atoms: { hasOlderMessages: hasOlderMessagesAtom, messagesList: messagesListAtom }, + // eslint-disable-next-line no-empty-function -- seeded atoms keep the drain loop a no-op + loadOlderMessages: async () => {}, +}; + +type ReactInternals = { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { + H: unknown; + }; +}; + +type HookDispatcher = { + useCallback: (callback: T, _deps?: unknown) => T; + useRef: (initial: T) => { current: T }; + useState: (initialValue: T) => [T, (value: T | ((previous: T) => T)) => void]; +}; + +type ContinueSessionResult = ReturnType; + +function runContinueSession(args: { + organizationId?: string; + models?: { id: string; variants: string[] }[]; +}): ContinueSessionResult { + const reactInternals = React as typeof React & ReactInternals; + const hookState: unknown[] = []; + const refs: { current: unknown }[] = []; + let hookIndex = 0; + let refIndex = 0; + + const dispatcher: HookDispatcher = { + useCallback: hookCallback => { + hookIndex += 1; + return hookCallback; + }, + useRef: initial => { + const index = refIndex; + refIndex += 1; + refs[index] ??= { current: initial }; + return refs[index] as { current: typeof initial }; + }, + useState: initialValue => { + const stateIndex = hookIndex; + hookIndex += 1; + if (hookState[stateIndex] === undefined) { + hookState[stateIndex] = initialValue; + } + const setState = ( + value: typeof initialValue | ((previous: typeof initialValue) => typeof initialValue) + ) => { + hookState[stateIndex] = + typeof value === 'function' + ? (value as (previous: typeof initialValue) => typeof initialValue)( + hookState[stateIndex] as typeof initialValue + ) + : value; + }; + return [hookState[stateIndex] as typeof initialValue, setState]; + }, + }; + + const previousDispatcher = + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H; + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- fake dispatcher drives the hook in a plain vitest run + return useContinueSession({ + organizationId: args.organizationId, + manager: manager as never, + models: args.models ?? [], + modelsLoading: false, + }); + } finally { + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = + previousDispatcher; + } +} + +const CLOUD_DESTINATION = { + kind: 'cloud-agent', + repo: 'owner/repo', + model: 'model-1', + variant: 'v1', +}; +const REMOTE_DESTINATION = { + kind: 'remote', + instance: { connectionId: 'conn-1', name: 'laptop', projectName: 'kilo' }, +}; +const FIELDS = { gitUrl: null, mode: 'code', model: 'model-1', variant: 'v1' }; + +function usedCloudKeys(): (string | undefined)[] { + return prepareSessionMutate.mock.calls.map( + call => (call[0] as { operationKey?: string }).operationKey + ); +} + +function usedRemoteKeys(): (string | undefined)[] { + return remoteSpawnMock.mock.calls.map( + call => (call[2] as { operationKey?: string } | undefined)?.operationKey + ); +} + +describe('useContinueSession cloud operationKey', () => { + beforeEach(() => { + prepareSessionMutate.mockReset(); + remoteSpawnMock.mockReset(); + routerPush.mockClear(); + queryClientFetchQuery.mockReset(); + toastError.mockClear(); + destinationsRef.value = [CLOUD_DESTINATION]; + // fetchQuery: first call is the repositories query, second the instances + // query (Promise.all preserves call order). Both must resolve for the + // destination resolution step to proceed. + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + queryClientFetchQuery.mockImplementation((options: { queryKey?: string[] }) => { + if (options.queryKey?.[0] === 'instances') { + return Promise.resolve({ instances: [] }); + } + return Promise.resolve({ repositories: [] }); + }); + }); + + it('keeps the same cloud operationKey across retryable creation_in_progress failures', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()) + .mockResolvedValueOnce({ kiloSessionId: 'ses_12345678901234567890123456' }); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession(FIELDS); + await hook.continueSession(FIELDS); + + const keys = usedCloudKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + prompt: 'seed-text', + githubRepo: 'owner/repo', + autoInitiate: true, + operationKey: expect.any(String), + }); + }); + + it('rotates the cloud operationKey after a successful prepare', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockResolvedValueOnce({ kiloSessionId: 'ses_12345678901234567890123456' }) + .mockRejectedValueOnce(creationInProgressError()); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession(FIELDS); + await hook.continueSession(FIELDS); + await hook.continueSession(FIELDS); + + const keys = usedCloudKeys(); + // The successful retry rides the same key as the retryable attempt. + expect(keys[1]).toBe(keys[0]); + // The submit after success is a fresh intent with a fresh key. + expect(keys[2]).not.toBe(keys[0]); + }); + + it('rotates the cloud operationKey after a typed non-retryable rejection', async () => { + prepareSessionMutate + .mockRejectedValueOnce(badRequestError()) + .mockRejectedValueOnce(creationInProgressError()); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession(FIELDS); + await hook.continueSession(FIELDS); + + const keys = usedCloudKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).not.toBe(keys[0]); + }); +}); + +describe('useContinueSession remote operationKey', () => { + beforeEach(() => { + prepareSessionMutate.mockReset(); + remoteSpawnMock.mockReset(); + routerPush.mockClear(); + queryClientFetchQuery.mockReset(); + toastError.mockClear(); + destinationsRef.value = [REMOTE_DESTINATION]; + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + queryClientFetchQuery.mockImplementation((options: { queryKey?: string[] }) => { + if (options.queryKey?.[0] === 'instances') { + return Promise.resolve({ instances: [] }); + } + return Promise.resolve({ repositories: [] }); + }); + }); + + it('keeps the same remote operationKey across retryable spawn outcomes', async () => { + remoteSpawnMock.mockResolvedValue(retryableOutcome()); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + + const keys = usedRemoteKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + // The operationKey rides the third spawn argument (dedupe mutationId). + expect(remoteSpawnMock.mock.calls[0]?.[0]).toBe('conn-1'); + expect(remoteSpawnMock.mock.calls[0]?.[2]).toMatchObject({ operationKey: expect.any(String) }); + }); + + it('rotates the remote operationKey after a ready spawn', async () => { + remoteSpawnMock + .mockResolvedValueOnce(retryableOutcome()) + .mockResolvedValueOnce(readyOutcome()) + .mockResolvedValueOnce(retryableOutcome()); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + + const keys = usedRemoteKeys(); + // The ready attempt rides the key from the retryable attempt. + expect(keys[1]).toBe(keys[0]); + // The spawn after ready is a fresh intent with a fresh key. + expect(keys[2]).not.toBe(keys[0]); + }); + + it('rotates the remote operationKey after a typed non-retryable spawn rejection', async () => { + remoteSpawnMock + .mockResolvedValueOnce(nonRetryableOutcome()) + .mockResolvedValueOnce(retryableOutcome()); + const hook = runContinueSession({ organizationId: 'org-1' }); + + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + + const keys = usedRemoteKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).not.toBe(keys[0]); + }); +}); + +describe('useContinueSession key separation', () => { + it('never shares a key between cloud prepares and remote spawns', async () => { + prepareSessionMutate.mockResolvedValueOnce({ kiloSessionId: 'ses_12345678901234567890123456' }); + remoteSpawnMock.mockResolvedValueOnce(retryableOutcome()); + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + queryClientFetchQuery.mockImplementation((options: { queryKey?: string[] }) => { + if (options.queryKey?.[0] === 'instances') { + return Promise.resolve({ instances: [] }); + } + return Promise.resolve({ repositories: [] }); + }); + const hook = runContinueSession({ organizationId: 'org-1' }); + + destinationsRef.value = [CLOUD_DESTINATION]; + await hook.continueSession(FIELDS); + + destinationsRef.value = [REMOTE_DESTINATION]; + await hook.continueSession({ gitUrl: null, mode: 'code', model: '', variant: '' }); + + const cloudKey = usedCloudKeys()[0]; + const remoteKey = usedRemoteKeys()[0]; + expect(cloudKey).toBeDefined(); + expect(remoteKey).toBeDefined(); + expect(remoteKey).not.toBe(cloudKey); + }); +}); diff --git a/apps/mobile/src/components/agents/use-continue-session.ts b/apps/mobile/src/components/agents/use-continue-session.ts index 3d3860b7fe..8d2828c88b 100644 --- a/apps/mobile/src/components/agents/use-continue-session.ts +++ b/apps/mobile/src/components/agents/use-continue-session.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- cloud prepare and remote spawn key rotation stay in the one continue hook. */ import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { useCallback, useRef, useState } from 'react'; import { type Href, useRouter } from 'expo-router'; @@ -15,6 +16,7 @@ import { resolveContinueRemoteModel, } from '@/components/agents/continuation-seed'; import { normalizeAgentMode } from '@/components/agents/mode-options'; +import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; import { appendNewSessionPrefill, buildContinuePrefillParams, @@ -31,6 +33,7 @@ import { buildCreateRemoteSessionInput, useRemoteInstanceSpawn, } from '@/lib/hooks/use-remote-instance-spawn'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { REMOTE_SPAWN_NON_RETRYABLE_TOAST, REMOTE_SPAWN_RETRYABLE_TOAST, @@ -67,9 +70,24 @@ export function useContinueSession(args: { const { spawn } = useRemoteInstanceSpawn(args.organizationId ?? null); const [isContinuing, setIsContinuing] = useState(false); const busyRef = useRef(false); + // P1-A-08b: one hoisted `operationKey` per submit intent for each + // destination family. Cloud prepares and remote spawns are different + // intents, so they never share a key; each is kept across retryable + // failures and rotated on success or a typed terminal rejection. + const cloudOperationKey = useHoistedOperationKey(); + const remoteOperationKey = useHoistedOperationKey(); const runCloudCreate = useCallback( async (seed: string, dest: { repo: string; model: string; variant: string }, mode: string) => { + const intentFingerprint = JSON.stringify({ + seed, + repo: dest.repo, + model: dest.model, + variant: dest.variant || undefined, + mode, + organizationId: args.organizationId ?? null, + }); + const operationKey = cloudOperationKey.getKey(intentFingerprint); const initialMessageId = generateMessageId(); const baseInput = { prompt: seed, @@ -80,19 +98,31 @@ export function useContinueSession(args: { githubRepo: dest.repo, autoCommit: true, autoInitiate: true, + operationKey, }; - const result = args.organizationId - ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ - ...baseInput, - organizationId: args.organizationId, - }) - : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); - captureEvent(SESSION_CREATED_EVENT, { surface: 'cloud-agent' }); - await invalidateAgentSessionQueries(queryClient, trpc); - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - router.push(getAgentSessionPath(result.kiloSessionId, args.organizationId)); + try { + const result = args.organizationId + ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ + ...baseInput, + organizationId: args.organizationId, + }) + : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); + // The intent settled; the next submit is a fresh intent. + cloudOperationKey.rotateKey(); + captureEvent(SESSION_CREATED_EVENT, { surface: 'cloud-agent' }); + await invalidateAgentSessionQueries(queryClient, trpc); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + router.push(getAgentSessionPath(result.kiloSessionId, args.organizationId)); + } catch (error) { + // A typed terminal rejection ends the intent; retryable failures + // (transport and `creation_in_progress`) keep the key. + if (!isCloudPrepareRetryableError(error)) { + cloudOperationKey.rotateKey(); + } + throw error; + } }, - [args.organizationId, queryClient, router, trpc] + [args.organizationId, queryClient, router, trpc, cloudOperationKey] ); const execute = useCallback( @@ -113,6 +143,16 @@ export function useContinueSession(args: { return; } const remoteModel = resolveContinueRemoteModel(fields.model, fields.variant, args.models); + const remoteOperationKeyValue = remoteOperationKey.getKey( + JSON.stringify({ + connectionId: dest.instance.connectionId, + seed, + model: remoteModel.model || undefined, + variant: remoteModel.variant || undefined, + mode: fields.mode, + organizationId: args.organizationId ?? null, + }) + ); const outcome = await spawn( dest.instance.connectionId, buildCreateRemoteSessionInput({ @@ -120,9 +160,12 @@ export function useContinueSession(args: { model: remoteModel.model, variant: remoteModel.variant, organizationId: args.organizationId, - }) + }), + { operationKey: remoteOperationKeyValue } ); if (outcome.status === 'ready') { + // The spawn settled; the next submit is a fresh intent. + remoteOperationKey.rotateKey(); const shareId = putSharePayload({ text: seed, files: [], failedFiles: [] }); router.push( appendShareParams( @@ -138,12 +181,17 @@ export function useContinueSession(args: { ? REMOTE_SPAWN_RETRYABLE_TOAST : REMOTE_SPAWN_NON_RETRYABLE_TOAST ); + // A typed non-retryable spawn rejection ends the intent; retryable + // outcomes keep the key so a same-key retry dedupes on the relay. + if (outcome.status === 'nonRetryable') { + remoteOperationKey.rotateKey(); + } } finally { busyRef.current = false; setIsContinuing(false); } }, - [args.organizationId, args.models, router, runCloudCreate, spawn] + [args.organizationId, args.models, router, runCloudCreate, spawn, remoteOperationKey] ); const fallback = useCallback( diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts new file mode 100644 index 0000000000..abb343da62 --- /dev/null +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -0,0 +1,223 @@ +/* eslint-disable import/first -- mocks must be defined before the module under test is imported */ +import * as React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// P1-A-08b: `useNewSessionCreator` must attach one stable `operationKey` per +// submit intent to `prepareSession`, keep it across retryable failures +// (incl. `creation_in_progress`), and rotate it on success or a typed +// non-retryable rejection. Run through a fake React dispatcher so the hook's +// own refs/callbacks are exercised without mounting React Native. + +const prepareSessionMutate = vi.hoisted(() => vi.fn()); +const routerPush = vi.hoisted(() => vi.fn()); +const navigationDispatch = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: routerPush }), + useNavigation: () => ({ dispatch: navigationDispatch }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({}), +})); +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + cloudAgentNext: { prepareSession: { mutate: prepareSessionMutate } }, + organizations: { cloudAgentNext: { prepareSession: { mutate: prepareSessionMutate } } }, + }, + useTRPC: () => ({}), +})); +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('sonner-native', () => ({ + toast: { error: toastError }, +})); +vi.mock('@kilocode/cloud-agent-sdk/message-id', () => ({ + generateMessageId: () => 'msg-1', +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + SESSION_CREATED_EVENT: 'session_created', +})); +vi.mock('@/lib/agent-session-cache', () => ({ + invalidateAgentSessionQueries: vi.fn(), +})); +// The real classifier lives in mobile-session-manager (covered by its own +// suite); this test only needs the retryable/non-retryable split. +vi.mock('@/components/agents/mobile-session-manager', () => ({ + isCloudPrepareRetryableError: (error: unknown) => { + const record = error as { data?: { code?: string }; message?: string }; + return record.data?.code === 'CONFLICT' && record.message === 'creation_in_progress'; + }, +})); +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `op-key-${n}`; + }, + }; +}); + +import { useNewSessionCreator } from './use-new-session-creator'; + +function creationInProgressError(): Error { + return Object.assign(new Error('creation_in_progress'), { data: { code: 'CONFLICT' } }); +} + +function badRequestError(): Error { + return Object.assign(new Error('session_creation_failed'), { data: { code: 'BAD_REQUEST' } }); +} + +type ReactInternals = { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { + H: unknown; + }; +}; + +type HookDispatcher = { + useCallback: (callback: T, _deps?: unknown) => T; + useRef: (initial: T) => { current: T }; +}; + +type CreatorResult = ReturnType; + +function runCreator(args: { + mode?: string; + model?: string; + variant?: string; + organizationId?: string; + selectedRepo?: string; +}): CreatorResult { + const reactInternals = React as typeof React & ReactInternals; + const refs: { current: unknown }[] = []; + let hookIndex = 0; + let refIndex = 0; + + const dispatcher: HookDispatcher = { + useCallback: hookCallback => { + hookIndex += 1; + return hookCallback; + }, + useRef: initial => { + const index = refIndex; + refIndex += 1; + refs[index] ??= { current: initial }; + return refs[index] as { current: typeof initial }; + }, + }; + + const previousDispatcher = + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H; + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- fake dispatcher drives the hook in a plain vitest run + return useNewSessionCreator({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- attachment fake shape, never read by the create path + attachments: { + attachments: [], + toWirePayload: () => null, + } as never, + mode: (args.mode ?? 'code') as never, + model: args.model ?? 'model-1', + organizationId: args.organizationId, + selectedRepo: args.selectedRepo ?? 'owner/repo', + // eslint-disable-next-line no-empty-function -- no-op state setter + setIsCreating: () => {}, + variant: args.variant ?? 'v1', + }); + } finally { + reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = + previousDispatcher; + } +} + +function usedOperationKeys(): (string | undefined)[] { + return prepareSessionMutate.mock.calls.map( + call => (call[0] as { operationKey?: string }).operationKey + ); +} + +describe('useNewSessionCreator operationKey', () => { + beforeEach(() => { + prepareSessionMutate.mockReset(); + routerPush.mockClear(); + navigationDispatch.mockClear(); + toastError.mockClear(); + }); + + it('keeps the same operationKey across retryable creation_in_progress failures', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + prompt: 'hello', + autoInitiate: true, + operationKey: expect.any(String), + }); + }); + + it('rotates the operationKey after a success so the next submit is a fresh intent', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockResolvedValueOnce({ + kiloSessionId: 'ses_12345678901234567890123456', + cloudAgentSessionId: 'c-1', + }) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + // The successful retry rides the same key as the retryable attempt. + expect(keys[1]).toBe(keys[0]); + // The submit after success is a fresh intent with a fresh key. + expect(keys[2]).not.toBe(keys[0]); + }); + + it('rotates the operationKey after a typed non-retryable rejection', async () => { + prepareSessionMutate + .mockRejectedValueOnce(badRequestError()) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).not.toBe(keys[0]); + }); + + it('treats a changed draft as a new intent with a new key', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + creator.promptRef.current = 'hello, changed'; + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[1]).not.toBe(keys[0]); + }); +}); diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index 4d07ef8006..bb92c74b6c 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -7,8 +7,10 @@ import { toast } from 'sonner-native'; import { type AgentMode } from '@/components/agents/mode-selector'; import { resolveNewSessionPromptForCreate } from '@/components/agents/new-session-prompt-state'; +import { isCloudPrepareRetryableError } from '@/components/agents/mobile-session-manager'; import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; import { captureEvent, SESSION_CREATED_EVENT } from '@/lib/analytics/posthog'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { type AgentAttachmentWire, type useAgentAttachmentUpload, @@ -51,6 +53,12 @@ export function useNewSessionCreator({ const queryClient = useQueryClient(); const trpc = useTRPC(); const promptRef = useRef(''); + // P1-A-08b: one `operationKey` per submit intent, hoisted so a retry of the + // same intent reuses the key (the ledger dedupes/reconciles instead of + // spawning a second session) and rotated on success or a typed terminal + // rejection. The fingerprint covers every intent-defining input, so a + // changed draft/selection becomes a fresh intent with a fresh key. + const { getKey, rotateKey } = useHoistedOperationKey(); const createSessionFromDraft = useCallback(async () => { // Read the live, post-settlement draft (see `settleVoiceInputBeforeSubmit` @@ -71,6 +79,16 @@ export function useNewSessionCreator({ setIsCreating(true); + const intentFingerprint = JSON.stringify({ + prompt, + mode, + model, + variant: variant || undefined, + repo: selectedRepo, + organizationId: organizationId ?? null, + }); + const operationKey = getKey(intentFingerprint); + try { const initialMessageId = generateMessageId(); const baseInput: { @@ -82,6 +100,7 @@ export function useNewSessionCreator({ githubRepo: string; autoCommit: boolean; autoInitiate: boolean; + operationKey: string; attachments?: AgentAttachmentWire; } = { prompt, @@ -92,6 +111,7 @@ export function useNewSessionCreator({ githubRepo: selectedRepo, autoCommit: true, autoInitiate: true, + operationKey, }; const wireAttachments = attachments.toWirePayload(); if (wireAttachments) { @@ -105,6 +125,10 @@ export function useNewSessionCreator({ }) : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); + // The intent settled (the ledger now owns the create); the next submit + // is a fresh intent with a fresh key. + rotateKey(); + captureEvent(SESSION_CREATED_EVENT, { surface: 'cloud-agent' }); await invalidateAgentSessionQueries(queryClient, trpc); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -124,6 +148,11 @@ export function useNewSessionCreator({ } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create session'; toast.error(message); + // A typed terminal rejection ends the intent; retryable failures + // (transport and `creation_in_progress`) keep the key. + if (!isCloudPrepareRetryableError(error)) { + rotateKey(); + } } finally { setIsCreating(false); } @@ -139,6 +168,8 @@ export function useNewSessionCreator({ navigation, attachments, setIsCreating, + getKey, + rotateKey, ]); return { createSessionFromDraft, promptRef }; diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts index 758976e0c4..27337fac3a 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- spawn input-chain suite pins key reuse/rotation in one coherent run. */ import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,7 +8,11 @@ import { peekSharePayload, type SharePayload, } from '@/lib/share-payload'; -import { buildCreateRemoteSessionInput } from '@/lib/hooks/remote-instance-spawn-classifier'; +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import { + buildCreateRemoteSessionInput, + type CreateSessionOutcome, +} from '@/lib/hooks/remote-instance-spawn-classifier'; import { RemoteSpawnInheritanceProvider, @@ -15,13 +20,19 @@ import { } from './use-remote-spawn-dispatch'; const spawnMock = vi.hoisted(() => - vi.fn(async () => { - await Promise.resolve(); - return { - status: 'ready' as const, - sessionID: 'ses_12345678901234567890123456', - }; - }) + vi.fn( + async ( + _connectionId: string, + _opts?: unknown, + _options?: unknown + ): Promise => { + await Promise.resolve(); + return { + status: 'ready', + sessionID: 'ses_12345678901234567890123456' as KiloSessionId, + }; + } + ) ); const useRemoteInstanceSpawnMock = vi.hoisted(() => @@ -41,7 +52,15 @@ vi.mock('sonner-native', () => ({ toast: { error: vi.fn() }, })); -vi.mock('expo-crypto', () => ({ randomUUID: () => 'share-id-fixed' })); +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `uuid-${n}`; + }, + }; +}); vi.mock('expo-file-system/legacy', () => ({ cacheDirectory: null, @@ -206,11 +225,15 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(spawnMock).toHaveBeenCalled(); }); - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { - agent: 'plan', - model: { providerID: 'kilo', modelID: 'kilo-auto/efficient', variant: 'medium' }, - orgId: 'org-xyz', - }); + expect(spawnMock).toHaveBeenCalledWith( + 'conn-abc', + { + agent: 'plan', + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient', variant: 'medium' }, + orgId: 'org-xyz', + }, + { operationKey: expect.any(String) } + ); }); it('onStart without inheritance yields org-only input — empty context regression', async () => { @@ -224,7 +247,11 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(spawnMock).toHaveBeenCalled(); }); - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { orgId: 'org-xyz' }); + expect(spawnMock).toHaveBeenCalledWith( + 'conn-abc', + { orgId: 'org-xyz' }, + { operationKey: expect.any(String) } + ); }); it('explicit mode/model/variant args win over empty context', async () => { @@ -247,7 +274,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { mode: 'code', model: 'anthropic/claude-sonnet-4', variant: 'high', - }) + }), + { operationKey: expect.any(String) } ); }); @@ -274,10 +302,14 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(spawnMock).toHaveBeenCalled(); }); - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { - agent: 'code', - model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, - }); + expect(spawnMock).toHaveBeenCalledWith( + 'conn-abc', + { + agent: 'code', + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + }, + { operationKey: expect.any(String) } + ); }); it('ready path stages the press-time payload and navigates with shareId + autoSend', async () => { @@ -292,14 +324,16 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(routerReplace).toHaveBeenCalled(); }); - const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; + const calledWith = routerReplace.mock.calls[0]?.[0] as string; expect(typeof calledWith).toBe('string'); expect(calledWith).toContain('spawned=1'); - expect(calledWith).toContain('shareId=share-id-fixed'); + expect(calledWith).toMatch(/shareId=uuid-\d+/); expect(calledWith).toContain('autoSend=1'); expect(calledWith).toContain('ses_12345678901234567890123456'); - const stored = peekSharePayload('share-id-fixed'); + const shareIdMatch = /shareId=([^&]+)/.exec(calledWith); + expect(shareIdMatch).not.toBeNull(); + const stored = peekSharePayload(decodeURIComponent(shareIdMatch?.[1] ?? '')); expect(stored).not.toBeNull(); expect(stored?.text).toBe('hello'); }); @@ -316,7 +350,7 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(routerReplace).toHaveBeenCalled(); }); - const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; + const calledWith = routerReplace.mock.calls[0]?.[0] as string; expect(typeof calledWith).toBe('string'); expect(calledWith).toContain('spawned=1'); expect(calledWith).toContain('ses_12345678901234567890123456'); @@ -335,12 +369,94 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(routerReplace).toHaveBeenCalled(); }); - const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; + const calledWith = routerReplace.mock.calls[0]?.[0] as string; expect(typeof calledWith).toBe('string'); expect(calledWith).toContain('spawned=1'); expect(calledWith).not.toContain('shareId='); expect(calledWith).not.toContain('autoSend='); }); + + it('reuses the same operationKey across retryable spawn outcomes', async () => { + const retryable = { + status: 'retryable' as const, + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }; + spawnMock.mockResolvedValueOnce(retryable).mockResolvedValueOnce(retryable); + const { onStart } = runHookWithProvider({ organizationId: 'org-xyz', withProvider: false }); + + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + const first = spawnMock.mock.calls[0]?.[2] as { operationKey?: string } | undefined; + const second = spawnMock.mock.calls[1]?.[2] as { operationKey?: string } | undefined; + expect(first?.operationKey).toBeDefined(); + expect(second?.operationKey).toBe(first?.operationKey); + }); + + it('rotates the operationKey after a ready outcome', async () => { + const retryable = { + status: 'retryable' as const, + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }; + spawnMock + .mockResolvedValueOnce(retryable) + .mockResolvedValueOnce({ + status: 'ready', + sessionID: 'ses_12345678901234567890123456' as KiloSessionId, + }) + .mockResolvedValueOnce(retryable); + const { onStart } = runHookWithProvider({ organizationId: 'org-xyz', withProvider: false }); + + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(3); + }); + + const keys = spawnMock.mock.calls.map( + call => (call[2] as { operationKey?: string } | undefined)?.operationKey + ); + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).not.toBe(keys[0]); + }); + + it('rotates the operationKey after a nonRetryable outcome', async () => { + const nonRetryable = { + status: 'nonRetryable' as const, + reason: 'CLI_UPGRADE_REQUIRED', + cause: new Error('CLI_UPGRADE_REQUIRED'), + }; + spawnMock.mockResolvedValueOnce(nonRetryable).mockResolvedValueOnce(nonRetryable); + const { onStart } = runHookWithProvider({ organizationId: 'org-xyz', withProvider: false }); + + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + const first = spawnMock.mock.calls[0]?.[2] as { operationKey?: string } | undefined; + const second = spawnMock.mock.calls[1]?.[2] as { operationKey?: string } | undefined; + expect(second?.operationKey).not.toBe(first?.operationKey); + }); }); // Smoke: Provider is a real React context provider (not a no-op export). diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts index 085f4101eb..88af47b2d6 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts @@ -18,9 +18,11 @@ import { buildCreateRemoteSessionInput, type CreateRemoteSessionInput, type CreateSessionOutcome, + type CreateSessionSpawnOptions, type RemoteInstanceSpawnStatus, useRemoteInstanceSpawn, } from '@/lib/hooks/use-remote-instance-spawn'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { REMOTE_SPAWN_NON_RETRYABLE_TOAST, REMOTE_SPAWN_RETRYABLE_TOAST, @@ -162,9 +164,18 @@ export function useRemoteSpawnDispatch({ // inherit by calling `useRemoteInstanceSpawn()` with no arg). const remoteSpawn: { status: RemoteInstanceSpawnStatus; - spawn: (connectionId: string, opts?: CreateRemoteSessionInput) => Promise; + spawn: ( + connectionId: string, + opts?: CreateRemoteSessionInput, + options?: CreateSessionSpawnOptions + ) => Promise; } = useRemoteInstanceSpawn(organizationId ?? null); const [showInstanceDisconnectedNote, setShowInstanceDisconnectedNote] = useState(false); + // P1-A-08b: one `operationKey` per spawn intent, hoisted so a retryable + // failure keeps the key (the relay dedupes the retry) and rotated on a + // terminal outcome (`ready` or a typed non-retryable rejection). The + // fingerprint covers the target instance and every create field. + const { getKey, rotateKey } = useHoistedOperationKey(); // kilocode_change - `onStart`'s async tail (spawn + refetch + classify) // outlives a single render; a plain closure over `runOnInstance` would @@ -209,9 +220,20 @@ export function useRemoteSpawnDispatch({ variant: fields.variant, organizationId: fields.organizationId, }); + const operationKey = getKey( + JSON.stringify({ + connectionId: selectedConnectionId, + mode: fields.mode, + model: fields.model, + variant: fields.variant, + organizationId: fields.organizationId, + }) + ); void (async () => { - const outcome = await remoteSpawn.spawn(selectedConnectionId, createInput); + const outcome = await remoteSpawn.spawn(selectedConnectionId, createInput, { operationKey }); if (outcome.status === 'ready') { + // The spawn settled; the next submit is a fresh intent. + rotateKey(); const spawnedPath = getSpawnedAgentSessionPath(outcome.sessionID, organizationId); if (submitPayload === null) { router.replace(spawnedPath); @@ -224,6 +246,8 @@ export function useRemoteSpawnDispatch({ return; } if (outcome.status === 'nonRetryable') { + // A typed non-retryable rejection ends the intent. + rotateKey(); toast.error(REMOTE_SPAWN_NON_RETRYABLE_TOAST); return; } @@ -271,6 +295,8 @@ export function useRemoteSpawnDispatch({ router, runOnInstance, setRunOnInstance, + getKey, + rotateKey, ]); const onChangeRunOnInstance = useCallback( diff --git a/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx new file mode 100644 index 0000000000..24850e4379 --- /dev/null +++ b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx @@ -0,0 +1,333 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +// P1-A-08b: the share gate must attach one hoisted `operationKey` per +// share-spawn intent (share + instance) to the `spawn` call, keep it across +// retryable outcomes (the relay dedupes the same-key retry), and rotate it +// on a terminal outcome (`ready` commit navigation or a typed non-retryable +// rejection). This suite mounts the real `ShareGateSheet` with every +// RN-touching dependency stubbed and drives the spawn via the list's +// captured `onSpawnInstance` prop. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + REMOTE_SPAWN_NON_RETRYABLE_TOAST, + REMOTE_SPAWN_RETRYABLE_TOAST, +} from '@/lib/remote-submit-outcome'; +import { __resetPendingShareNavigationForTests } from '@/lib/share-navigation'; +import { __resetSharePayloadStoreForTests, putSharePayload } from '@/lib/share-payload'; +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import { type CreateSessionOutcome } from '@/lib/hooks/remote-instance-spawn-classifier'; +import { type ShareCliSpawnRow } from './share-cli-spawn'; +import { ShareGateSheet } from './share-gate-sheet'; + +const spawnMock = vi.hoisted(() => + vi.fn( + // eslint-disable-next-line require-await, typescript-eslint/require-await -- mock returns a settled outcome without awaiting + async ( + _connectionId: string, + _opts?: unknown, + _options?: unknown + ): Promise => ({ + status: 'retryable', + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }) + ) +); +const toastError = vi.hoisted(() => vi.fn()); +const routerBack = vi.hoisted(() => vi.fn()); +const refetchInstancesMock = vi.hoisted(() => vi.fn(() => undefined)); +const alertMock = vi.hoisted(() => vi.fn()); +const shareDestinationListProps = vi.hoisted(() => ({ + current: null as { + onSpawnInstance: (row: ShareCliSpawnRow) => void; + instanceRowsDisabled: boolean; + } | null, +})); +const instanceRows = vi.hoisted(() => [ + { connectionId: 'conn-1', name: 'laptop', projectName: 'kilo' }, +]); + +vi.mock('react-native', () => ({ + Alert: { alert: alertMock }, + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: routerBack }), +})); +vi.mock('expo-haptics', () => ({ + selectionAsync: vi.fn(), + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'success' }, +})); +vi.mock('lucide-react-native', () => ({ + Plus: 'Plus', + X: 'X', +})); +vi.mock('sonner-native', () => ({ + toast: { error: toastError }, +})); +vi.mock('@/components/ui/button', () => ({ + Button: 'Button', +})); +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({}), +})); +vi.mock('@/lib/organization-context', () => ({ + useOrganization: () => ({ organizationId: null, isLoaded: true }), +})); +vi.mock('@/lib/hooks/use-agent-sessions', () => ({ + useAgentSessions: () => ({ + storedSessions: [], + activeSessionIds: new Set(), + activeSessions: [], + storedIsError: false, + storedIsSuccess: true, + activeIsError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock('@/lib/hooks/use-remote-instance-spawn', () => ({ + useRemoteInstanceSpawn: () => ({ spawn: spawnMock }), +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + activeSessions: { listInstances: { queryOptions: () => ({ queryKey: ['instances'] }) } }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: { instances: instanceRows }, refetch: refetchInstancesMock }), +})); +vi.mock('expo-crypto', () => { + let n = 0; + return { + randomUUID: () => { + n += 1; + return `uuid-${n}`; + }, + }; +}); +vi.mock('expo-file-system/legacy', () => ({ + cacheDirectory: null, + copyAsync: vi.fn().mockResolvedValue('/tmp/copy'), + deleteAsync: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('expo-share-intent', () => ({})); +vi.mock('@/components/agents/session-list-helpers', () => ({ + expandPlatformFilter: (value: unknown) => value, +})); +vi.mock('./share-payload-preview', () => ({ + SharePayloadPreview: 'SharePayloadPreview', +})); +// Deterministic validation: no file measuring, no upload-task dynamic import. +vi.mock('./share-payload-validation', () => ({ + validateSharePayload: () => ({ + kind: 'ok' as const, + accepted: [], + rejectedNotes: [], + truncated: false, + usable: true, + }), +})); +// Capture the list props so tests drive `onSpawnInstance` directly. +vi.mock('./share-destination-list', () => ({ + ShareDestinationList: (props: { + onSpawnInstance: (row: ShareCliSpawnRow) => void; + instanceRowsDisabled: boolean; + }) => { + shareDestinationListProps.current = props; + return null; + }, +})); + +const INSTANCE: ShareCliSpawnRow = { + connectionId: 'conn-1', + name: 'laptop', + projectName: 'kilo', +}; + +function retryableOutcome() { + return { + status: 'retryable' as const, + reason: 'Connection destroyed', + cause: new Error('Connection destroyed'), + }; +} + +function readyOutcome(): CreateSessionOutcome { + return { + status: 'ready', + sessionID: 'ses_12345678901234567890123456' as KiloSessionId, + }; +} + +function nonRetryableOutcome() { + return { + status: 'nonRetryable' as const, + reason: 'CLI_UPGRADE_REQUIRED', + cause: new Error('CLI_UPGRADE_REQUIRED'), + }; +} + +async function mountGate(shareId: string): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(ShareGateSheet, { shareId })); + }); + // Flush the async payload-validation effect so `commitEnabled` flips true + // (and the instance rows stop being disabled) before any press. + await act(async () => { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function captureListProps(): { + onSpawnInstance: (row: ShareCliSpawnRow) => void; + instanceRowsDisabled: boolean; +} { + const props = shareDestinationListProps.current; + if (!props) { + throw new Error('ShareDestinationList was not rendered'); + } + return props; +} + +async function flushAsync(): Promise { + await act(async () => { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + }); +} + +async function pressSpawn(onSpawnInstance: (row: ShareCliSpawnRow) => void): Promise { + act(() => { + onSpawnInstance(INSTANCE); + }); + await flushAsync(); +} + +function usedOperationKeys(): (string | undefined)[] { + return spawnMock.mock.calls.map( + call => (call[2] as { operationKey?: string } | undefined)?.operationKey + ); +} + +describe('ShareGateSheet spawn operationKey wiring', () => { + beforeEach(() => { + spawnMock.mockClear(); + spawnMock.mockResolvedValue(retryableOutcome()); + toastError.mockClear(); + routerBack.mockClear(); + refetchInstancesMock.mockClear(); + alertMock.mockClear(); + shareDestinationListProps.current = null; + __resetSharePayloadStoreForTests(); + __resetPendingShareNavigationForTests(); + }); + + it('passes the hoisted operationKey to spawn on a CLI instance press', async () => { + const shareId = putSharePayload({ text: 'hello', files: [], failedFiles: [] }); + const renderer = await mountGate(shareId); + const list = captureListProps(); + expect(list.instanceRowsDisabled).toBe(false); + + await pressSpawn(list.onSpawnInstance); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith('conn-1', undefined, { + operationKey: expect.any(String), + }); + + act(() => { + renderer.unmount(); + }); + }); + + it('keeps the same operationKey across retryable spawn outcomes', async () => { + const shareId = putSharePayload({ text: 'hello', files: [], failedFiles: [] }); + const renderer = await mountGate(shareId); + const list = captureListProps(); + + await pressSpawn(list.onSpawnInstance); + await pressSpawn(list.onSpawnInstance); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + expect(toastError).toHaveBeenCalledWith(REMOTE_SPAWN_RETRYABLE_TOAST); + expect(refetchInstancesMock).toHaveBeenCalled(); + + act(() => { + renderer.unmount(); + }); + }); + + it('rotates the operationKey after a ready spawn commit navigation', async () => { + const shareId = putSharePayload({ text: 'hello', files: [], failedFiles: [] }); + const renderer = await mountGate(shareId); + const list = captureListProps(); + + spawnMock + .mockResolvedValueOnce(retryableOutcome()) + .mockResolvedValueOnce(readyOutcome()) + .mockResolvedValueOnce(retryableOutcome()); + + await pressSpawn(list.onSpawnInstance); + await pressSpawn(list.onSpawnInstance); + await pressSpawn(list.onSpawnInstance); + + const keys = usedOperationKeys(); + // The ready attempt rides the key from the retryable attempt. + expect(keys[1]).toBe(keys[0]); + // The press after ready is a fresh intent with a fresh key. + expect(keys[2]).not.toBe(keys[0]); + expect(routerBack).toHaveBeenCalled(); + + act(() => { + renderer.unmount(); + }); + }); + + it('rotates the operationKey after a typed non-retryable spawn rejection', async () => { + const shareId = putSharePayload({ text: 'hello', files: [], failedFiles: [] }); + const renderer = await mountGate(shareId); + const list = captureListProps(); + + spawnMock + .mockResolvedValueOnce(nonRetryableOutcome()) + .mockResolvedValueOnce(nonRetryableOutcome()); + + await pressSpawn(list.onSpawnInstance); + await pressSpawn(list.onSpawnInstance); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).not.toBe(keys[0]); + expect(toastError).toHaveBeenCalledWith(REMOTE_SPAWN_NON_RETRYABLE_TOAST); + // A non-retryable rejection must not refetch or navigate. + expect(refetchInstancesMock).not.toHaveBeenCalled(); + expect(routerBack).not.toHaveBeenCalled(); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index 24a8a79e37..c328ff5687 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -19,6 +19,7 @@ import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { useRemoteInstanceSpawn } from '@/lib/hooks/use-remote-instance-spawn'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; +import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { resolveRemoteSubmitOutcome } from '@/lib/remote-submit-outcome'; import { appendShareParams, setPendingShareNavigation } from '@/lib/share-navigation'; import { clearSharePayload, peekSharePayload, type ShareId } from '@/lib/share-payload'; @@ -64,6 +65,12 @@ export function ShareGateSheet({ shareId }: Readonly) { const { spawn } = useRemoteInstanceSpawn(); const [spawningConnectionId, setSpawningConnectionId] = useState(null); + // P1-A-08b: one `operationKey` per share-spawn intent (share + instance), + // hoisted so a retryable failure keeps the key (the relay dedupes the + // retry) and rotated on a terminal outcome (`ready` navigation or a typed + // non-retryable rejection). Uses the share flow's own key, never the + // new-session or continue flows' keys. + const { getKey, rotateKey } = useHoistedOperationKey(); // Per-attempt token so a stale spawn's finally cannot clear a newer lock // (share replace mid-flight, or same-connection re-tap after replace). const spawnAttemptRef = useRef(0); @@ -271,8 +278,11 @@ export function ShareGateSheet({ shareId }: Readonly) { spawnAttemptRef.current += 1; const attempt = spawnAttemptRef.current; setSpawningConnectionId(instance.connectionId); + const operationKey = getKey( + JSON.stringify({ connectionId: instance.connectionId, shareId }) + ); try { - const outcome = await spawn(instance.connectionId); + const outcome = await spawn(instance.connectionId, undefined, { operationKey }); // Gate has no "Run on" selection; ignore selection-reset flags. const action = resolveRemoteSubmitOutcome({ outcome, @@ -289,6 +299,8 @@ export function ShareGateSheet({ shareId }: Readonly) { ) { return; } + // The spawn settled; the next share attempt is a fresh intent. + rotateKey(); commit( appendShareParams(getSpawnedAgentSessionPath(action.sessionID) as string, shareId) ); @@ -305,6 +317,8 @@ export function ShareGateSheet({ shareId }: Readonly) { return; } + // A typed non-retryable rejection ends the intent. + rotateKey(); toast.error(action.toast); } finally { // Only the attempt that still owns the lock may clear it. @@ -314,7 +328,18 @@ export function ShareGateSheet({ shareId }: Readonly) { } })(); }, - [commit, commitEnabled, isSpawning, payload, refetchInstances, shareId, spawn, validation] + [ + commit, + commitEnabled, + getKey, + isSpawning, + payload, + refetchInstances, + rotateKey, + shareId, + spawn, + validation, + ] ); const handleRetry = useCallback(() => { diff --git a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts index 5aa62b74fb..bd363386d7 100644 --- a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts +++ b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts @@ -205,14 +205,26 @@ export function mergeSpawnOrganizationId( // Spawner // --------------------------------------------------------------------------- +/** + * Options for one `spawn` attempt. `operationKey` is the caller's stable + * per-user-intent key, forwarded to the SDK as `mutationId` so the relay's + * UserConnectionDO dedupes duplicate sends and replays the durable terminal + * result under the same key. + */ +export type CreateSessionSpawnOptions = { + /** Stable per-user-intent key; becomes the SDK `mutationId` on the wire. */ + operationKey?: string; +}; + /** * Stable per-spawner identity (UUID v4). Generated once at spawner creation. * - * v1 does NOT use `creationKey` for server-side dedup — the relay/CLI has no - * idempotency layer for `create_session` and an existing connection's race - * is a real (but small) possibility. The key exists purely as a stable - * per-attempt identifier for in-hook bookkeeping and tests; do not build a - * dedupe layer on top of it without revisiting the contract. + * Server-side dedup for `create_session` rides the caller's per-intent + * `operationKey`, which the spawner forwards to the SDK as `mutationId` + * (the relay's UserConnectionDO dedupes by it; see `CreateSessionSpawnOptions`). + * `creationKey` is NOT that key: it remains a stable per-spawner identifier + * for in-hook bookkeeping and tests only. Do not build a dedupe layer on top + * of `creationKey`. */ export type CreateSessionSpawner = { readonly creationKey: string; @@ -220,7 +232,11 @@ export type CreateSessionSpawner = { * Attempt a `create_session` against the given CLI connection. Returns * the classified outcome — never throws. */ - spawn: (connectionId: string, opts?: CreateRemoteSessionInput) => Promise; + spawn: ( + connectionId: string, + opts?: CreateRemoteSessionInput, + options?: CreateSessionSpawnOptions + ) => Promise; }; function generateCreationKey(): string { @@ -249,9 +265,13 @@ export function createSessionSpawner( const creationKey = generateCreationKey(); return { creationKey, - async spawn(connectionId, opts) { + async spawn(connectionId, opts, options) { + const input: CreateRemoteSessionInput = { + ...opts, + ...(options?.operationKey !== undefined ? { mutationId: options.operationKey } : {}), + }; try { - const raw = await createRemoteSessionOnConnection(connection, connectionId, opts); + const raw = await createRemoteSessionOnConnection(connection, connectionId, input); return classifyCreateSessionResult({ status: 'fulfilled', value: raw }); } catch (error) { return classifyCreateSessionResult({ status: 'rejected', reason: error }); diff --git a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts index 780de341d8..34213e6449 100644 --- a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts +++ b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts @@ -16,11 +16,12 @@ import { type CreateRemoteSessionInput, type CreateSessionOutcome, createSessionSpawner, + type CreateSessionSpawnOptions, mergeSpawnOrganizationId, resolveSpawnOrganizationId, } from './remote-instance-spawn-classifier'; -export type { CreateRemoteSessionInput, CreateSessionOutcome }; +export type { CreateRemoteSessionInput, CreateSessionOutcome, CreateSessionSpawnOptions }; export { buildCreateRemoteSessionInput }; export type RemoteInstanceSpawnStatus = @@ -39,6 +40,11 @@ export type RemoteInstanceSpawnStatus = * underlying SDK call is one-shot per `spawn()` call — no in-hook retry * loop, no toast, no debouncing; the caller drives those. * + * The caller may pass a stable per-intent `operationKey` in the third + * `spawn()` argument; the spawner forwards it to the SDK as `mutationId` + * so a retry of the same intent dedupes against the relay instead of + * spawning a second session. + * * `organizationId` tri-state: * - omitted (`undefined`) — inherit live `useOrganization()` (share-gate * and other zero-arg callers that intentionally follow global context) @@ -50,7 +56,11 @@ export type RemoteInstanceSpawnStatus = */ export function useRemoteInstanceSpawn(organizationId?: string | null): { status: RemoteInstanceSpawnStatus; - spawn: (connectionId: string, opts?: CreateRemoteSessionInput) => Promise; + spawn: ( + connectionId: string, + opts?: CreateRemoteSessionInput, + options?: CreateSessionSpawnOptions + ) => Promise; } { const connection = useUserWebConnection(); const { organizationId: contextOrganizationId } = useOrganization(); @@ -63,11 +73,12 @@ export function useRemoteInstanceSpawn(organizationId?: string | null): { const spawn = async ( connectionId: string, - opts?: CreateRemoteSessionInput + opts?: CreateRemoteSessionInput, + options?: CreateSessionSpawnOptions ): Promise => { setStatus({ status: 'inFlight' }); const merged = mergeSpawnOrganizationId(opts, resolvedOrganizationId); - const outcome = await spawner.spawn(connectionId, merged); + const outcome = await spawner.spawn(connectionId, merged, options); setStatus({ ...outcome, creationKey: spawner.creationKey }); return outcome; }; From 095738bf2faa5602b97debf61b9b5e9b5eb0acfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 11:41:42 +0200 Subject: [PATCH 16/56] fix(mobile): verify remote operation key tests --- .../hooks/use-remote-instance-spawn.test.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts index 37dc87ec4d..8c934f782c 100644 --- a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts +++ b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- the classifier/spawner suite pins the SDK create-session contract in one file. */ import { describe, expect, it, vi } from 'vitest'; import { type KiloSessionId, type UserWebConnection } from '@kilocode/cloud-agent-sdk'; @@ -251,7 +252,10 @@ describe('createSessionSpawner', () => { it('spawn forwards CreateRemoteSessionInput to createRemoteSessionOnConnection', async () => { // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point - const send = vi.fn(async () => ({ protocolVersion: 1, sessionID: VALID_SESSION_ID })); + const send = vi.fn(async (_input: unknown) => ({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + })); const spawner = createSessionSpawner(makeConnection(send)); const opts = { agent: 'code', @@ -283,6 +287,50 @@ describe('createSessionSpawner', () => { }); }); + it('spawn forwards the caller operationKey as the SDK mutationId (ext attempt)', async () => { + // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point + const send = vi.fn(async () => ({ protocolVersion: 1, sessionID: VALID_SESSION_ID })); + const spawner = createSessionSpawner(makeConnection(send)); + await spawner.spawn('cli-owner-1', { agent: 'code' }, { operationKey: 'op-key-1' }); + expect(send).toHaveBeenCalledWith({ + command: 'create_session', + data: { protocolVersion: 1, agent: 'code' }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'op-key-1:ext', + }); + }); + + it('spawn forwards the operationKey even when no create opts are given', async () => { + // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point + const send = vi.fn(async () => ({ protocolVersion: 1, sessionID: VALID_SESSION_ID })); + const spawner = createSessionSpawner(makeConnection(send)); + await spawner.spawn('cli-owner-1', undefined, { operationKey: 'op-key-2' }); + expect(send).toHaveBeenCalledWith({ + command: 'create_session', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'op-key-2:ext', + }); + }); + + it('omits mutationId from the wire when no operationKey is supplied', async () => { + // eslint-disable-next-line typescript-eslint/require-await -- no await needed; return value is the whole point + const send = vi.fn(async (_input: unknown) => ({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + })); + const spawner = createSessionSpawner(makeConnection(send)); + await spawner.spawn('cli-owner-1', { agent: 'code' }); + // Prove the command was actually sent with the expected command/data/ + // connection before asserting the mutationId field is absent. + expect(send).toHaveBeenCalledWith({ + command: 'create_session', + data: { protocolVersion: 1, agent: 'code' }, + expectedConnectionId: 'cli-owner-1', + }); + expect(send.mock.calls[0]?.[0]).not.toHaveProperty('mutationId'); + }); + it('spawn wraps delivered bare-string errors via the classifier', async () => { const spawner = createSessionSpawner( // eslint-disable-next-line typescript-eslint/require-await -- no await needed; throw is the whole point @@ -304,4 +352,23 @@ describe('createSessionSpawner', () => { const outcome = await spawner.spawn('cli-owner-1'); expect(outcome.status).toBe('retryable'); }); + + it('classifies a replayed durable envelope identically to the live envelope', () => { + // D8 replay contract: the relay returns the stored terminal result under + // the retry request's mutationId with exactly the live envelope's shape, + // so the classifier must produce the identical outcome for both. The live + // and replayed envelopes are constructed independently so the comparison + // proves shape equivalence, not object identity. + const liveReady = { protocolVersion: 1, sessionID: VALID_SESSION_ID }; + const replayedReady = { protocolVersion: 1, sessionID: VALID_SESSION_ID }; + expect(classifyCreateSessionResult({ status: 'fulfilled', value: liveReady })).toEqual( + classifyCreateSessionResult({ status: 'fulfilled', value: replayedReady }) + ); + + const liveFailure = new CommandDeliveredError('failed to create session'); + const replayedFailure = new CommandDeliveredError('failed to create session'); + expect(classifyCreateSessionResult({ status: 'rejected', reason: liveFailure })).toEqual( + classifyCreateSessionResult({ status: 'rejected', reason: replayedFailure }) + ); + }); }); From 1035604661bc4e9b249625b99f8bfb541577b3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 12:06:03 +0200 Subject: [PATCH 17/56] fix(security): repair disabled routing and dismissal states --- .../lib/hooks/use-security-findings.test.ts | 14 +- .../src/lib/hooks/use-security-findings.ts | 9 +- .../services/manual-dismiss-client.test.ts | 55 ++++++ .../services/manual-dismiss-client.ts | 27 ++- .../services/manual-sync-client.test.ts | 49 ++++++ .../services/manual-sync-client.ts | 28 ++- ...organization-members-router.ledger.test.ts | 65 ++++++- .../organization-members-router.ts | 159 +++++++++++++----- 8 files changed, 346 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-security-findings.test.ts b/apps/mobile/src/lib/hooks/use-security-findings.test.ts index 8e399c37f6..2ebe8c70c6 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.test.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.test.ts @@ -183,12 +183,22 @@ describe('useDismissSecurityFinding (P1-A-08e wiring)', () => { expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); }); - it('keeps the key on an in-progress CONFLICT (same-key retry reconciles)', async () => { + it('keeps the key on an in-progress CONFLICT and maps it onto retryable copy', async () => { personalDismissMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); useDismissSecurityFinding('personal'); await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ - message: 'operation_in_progress', + message: 'A security sync is already in progress. Please try again.', + }); + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + }); + + it('maps an org in-progress dismissal CONFLICT onto retryable copy', async () => { + orgDismissMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useDismissSecurityFinding(ORG_ID); + + await expect(lastCapturedOptions?.mutationFn?.(DISMISS_VARS)).rejects.toMatchObject({ + message: 'A security sync is already in progress. Please try again.', }); expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/src/lib/hooks/use-security-findings.ts b/apps/mobile/src/lib/hooks/use-security-findings.ts index 2128314617..abcd2b521e 100644 --- a/apps/mobile/src/lib/hooks/use-security-findings.ts +++ b/apps/mobile/src/lib/hooks/use-security-findings.ts @@ -9,7 +9,10 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tansta import { toast } from 'sonner-native'; import { trackSecurityAgentCommand } from '@/lib/hooks/use-security-agent-commands'; -import { isSecuritySyncRetryable } from '@/lib/hooks/use-security-agent-mutations'; +import { + isSecuritySyncRetryable, + mapSecuritySyncOperationError, +} from '@/lib/hooks/use-security-agent-mutations'; import { useHoistedOperationKey } from '@/lib/pr-review/merge/pr-operation-ledger'; import { type SecurityAnalysis } from '@/lib/security-agent'; import { trpcClient, useTRPC } from '@/lib/trpc'; @@ -138,7 +141,9 @@ export function useDismissSecurityFinding(scope: string) { if (!isSecuritySyncRetryable(error)) { rotateKey(); } - throw error; + // Map the raw `operation_in_progress` CONFLICT marker onto retryable + // copy before the form renders it inline (P2). + throw mapSecuritySyncOperationError(error); } }, onSuccess: result => { diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts index fe5e9c0489..eaa4137b36 100644 --- a/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.test.ts @@ -106,6 +106,61 @@ describe('submitManualFindingDismissal', () => { expect((captured as TRPCError).message).not.toContain('test-internal-secret'); }); + it('classifies the known disabled-routing 503 as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + json: () => + Promise.resolve({ success: false, error: 'Finding dismissal Worker routing is disabled' }), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('PRECONDITION_FAILED'); + expect((captured as TRPCError).message).toContain('503'); + // The known body is matched, never echoed back into the message. + expect((captured as TRPCError).message).not.toContain('disabled'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); + + it('keeps a 503 with a non-matching body ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + json: () => Promise.resolve({ success: false, error: 'gateway upstream unavailable' }), + }); + + let captured: unknown; + try { + await submitManualFindingDismissal({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + findingId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + installationId: 'installation-123', + reason: 'not_used', + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + expect((captured as TRPCError).message).toContain('503'); + }); + it('classifies a 4xx status as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts index 96a9c5cf72..8016f7362e 100644 --- a/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts +++ b/apps/web/src/lib/security-agent/services/manual-dismiss-client.ts @@ -37,6 +37,12 @@ type ManualFindingDismissalWorkerResponse = { error?: string; }; +// The Worker's known disabled-routing response: returned with status 503 +// BEFORE enqueueing when command routing is paused. It is a definitive +// pre-acceptance rejection — the Worker will never accept while routing is +// paused — not ambiguous transport. +const FINDING_DISMISSAL_DISABLED_ROUTING_ERROR = 'Finding dismissal Worker routing is disabled'; + export async function submitManualFindingDismissal( params: SubmitManualFindingDismissalParams ): Promise { @@ -97,6 +103,20 @@ export async function submitManualFindingDismissal( } if (!response.ok) { + // The known disabled-routing 503 is a definitive pre-acceptance rejection: + // the Worker returns it before enqueueing, so the row settles `failed` and + // a later retry must be a fresh intent. Match the exact known body only — + // a gateway 503 with arbitrary HTML stays ambiguous transport below. + if ( + response.status === 503 && + body?.success === false && + body?.error === FINDING_DISMISSAL_DISABLED_ROUTING_ERROR + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Security dismissal service request failed (status ${response.status}).`, + }); + } // A 5xx is ambiguous transport: the Worker may or may not have accepted. if (response.status >= 500) { throw new TRPCError({ @@ -104,9 +124,10 @@ export async function submitManualFindingDismissal( message: `Security dismissal service request failed (status ${response.status}). Try again.`, }); } - // A 4xx is a definitive pre-acceptance rejection. Do not blindly - // interpolate body.error — the worker may not be ours and the body can be - // attacker/gateway-controlled HTML. Keep the message short and non-secret. + // A 4xx is a definitive pre-acceptance rejection (validation, auth). + // Do not blindly interpolate body.error — the worker may not be ours and + // the body can be attacker/gateway-controlled HTML. Keep the message short + // and non-secret. throw new TRPCError({ code: 'PRECONDITION_FAILED', message: `Security dismissal service request failed (status ${response.status}).`, diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts index 7c3d4377f7..55d15308d3 100644 --- a/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.test.ts @@ -133,6 +133,55 @@ describe('submitManualSecuritySync', () => { }); }); + it('classifies the known disabled-routing 503 as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + json: () => + Promise.resolve({ success: false, error: 'Manual sync Worker routing is disabled' }), + }); + + let captured: unknown; + try { + await submitManualSecuritySync({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('PRECONDITION_FAILED'); + expect((captured as TRPCError).message).toContain('503'); + // The known body is matched, never echoed back into the message. + expect((captured as TRPCError).message).not.toContain('disabled'); + expect((captured as TRPCError).message).not.toContain('security-sync.test'); + expect((captured as TRPCError).message).not.toContain('test-internal-secret'); + }); + + it('keeps a 503 with a non-matching body ambiguous transport (BAD_GATEWAY)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + json: () => Promise.resolve({ success: false, error: 'gateway upstream unavailable' }), + }); + + let captured: unknown; + try { + await submitManualSecuritySync({ + owner: { organizationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + actor: { id: 'user-123' }, + }); + throw new Error('expected throw'); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(TRPCError); + expect((captured as TRPCError).code).toBe('BAD_GATEWAY'); + expect((captured as TRPCError).message).toContain('503'); + }); + it('classifies a 4xx status as a definitive pre-acceptance rejection (PRECONDITION_FAILED)', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/apps/web/src/lib/security-agent/services/manual-sync-client.ts b/apps/web/src/lib/security-agent/services/manual-sync-client.ts index fa7c8dde00..6012b856e1 100644 --- a/apps/web/src/lib/security-agent/services/manual-sync-client.ts +++ b/apps/web/src/lib/security-agent/services/manual-sync-client.ts @@ -35,6 +35,12 @@ type ManualSecuritySyncWorkerResponse = { error?: string; }; +// The Worker's known disabled-routing response: returned with status 503 +// BEFORE enqueueing when command routing is paused. It is a definitive +// pre-acceptance rejection — the Worker will never accept while routing is +// paused — not ambiguous transport. +const MANUAL_SYNC_DISABLED_ROUTING_ERROR = 'Manual sync Worker routing is disabled'; + export async function submitManualSecuritySync( params: SubmitManualSecuritySyncParams ): Promise { @@ -93,6 +99,20 @@ export async function submitManualSecuritySync( } if (!response.ok) { + // The known disabled-routing 503 is a definitive pre-acceptance rejection: + // the Worker returns it before enqueueing, so the row settles `failed` and + // a later retry must be a fresh intent. Match the exact known body only — + // a gateway 503 with arbitrary HTML stays ambiguous transport below. + if ( + response.status === 503 && + body?.success === false && + body?.error === MANUAL_SYNC_DISABLED_ROUTING_ERROR + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Security sync service request failed (status ${response.status}).`, + }); + } // A 5xx is ambiguous transport: the Worker may or may not have accepted. if (response.status >= 500) { throw new TRPCError({ @@ -100,10 +120,10 @@ export async function submitManualSecuritySync( message: `Security sync service request failed (status ${response.status}). Try again.`, }); } - // A 4xx is a definitive pre-acceptance rejection (validation, auth, - // disabled routing). Do not blindly interpolate body.error — the worker - // may not be ours and the body can be attacker/gateway-controlled HTML. - // Keep the message short and non-secret. + // A 4xx is a definitive pre-acceptance rejection (validation, auth). + // Do not blindly interpolate body.error — the worker may not be ours and + // the body can be attacker/gateway-controlled HTML. Keep the message short + // and non-secret. throw new TRPCError({ code: 'PRECONDITION_FAILED', message: `Security sync service request failed (status ${response.status}).`, diff --git a/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts b/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts index 622bc7b0d7..ca1446b7b8 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ledger.test.ts @@ -466,7 +466,7 @@ describe('organizations members ledger (P1-A-08e)', () => { expect(mockRevokeGatewayStateForOrganizationMember).not.toHaveBeenCalled(); }); - it('replays a settled duplicate without re-running the helper', async () => { + it('replays a settled duplicate without re-running the helper, even when the member is already gone', async () => { mockAdmitOperation.mockResolvedValue({ admission: 'duplicate_settled', row: ledgerRow({ @@ -476,7 +476,9 @@ describe('organizations members ledger (P1-A-08e)', () => { canonical_result: { updated: MEMBER_ID }, }), }); - mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + // The member is already removed: the missing-member precondition must not + // block the settled replay (admission runs before the precondition). + mockDbState.removeTargetMember = []; const result = await caller.remove(input); @@ -502,6 +504,60 @@ describe('organizations members ledger (P1-A-08e)', () => { }); expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); }); + + it('settles the row failed when a first-time removal finds the member already gone', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = []; + + await expect(caller.remove(input)).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'User is not a member of this organization', + }); + + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'failed', + outcomeCode: 'member_absent', + }) + ); + expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + }); + + it('settles the row failed when the target is a service account (bot)', async () => { + mockAdmitOperation.mockResolvedValue({ + admission: 'admitted', + row: ledgerRow({ + intent: 'member_remove', + resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, + }), + }); + mockDbState.removeTargetMember = [{ role: 'member', isBot: true }]; + + await expect(caller.remove(input)).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: 'Service account users cannot be removed', + }); + + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'org-ledger-row-id', + status: 'failed', + outcomeCode: 'bot_removal_refused', + }) + ); + expect(mockRemoveUserFromOrganization).not.toHaveBeenCalled(); + expect(mockCreateAuditLog).not.toHaveBeenCalled(); + }); }); describe('remove: read-back takeover repair for member removal', () => { @@ -519,7 +575,10 @@ describe('organizations members ledger (P1-A-08e)', () => { resource_key: `organization:${ORG_ID}:member:${MEMBER_ID}`, }), }); - mockDbState.removeTargetMember = [{ role: 'member', isBot: false }]; + // The member is already removed when the retry arrives (lost response + // after the first removal committed): the missing-member precondition + // must not run before the ledger — the takeover repair handles it. + mockDbState.removeTargetMember = []; mockDbState.memberReadBack = []; const result = await caller.remove(input); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index f99002a402..c60f922fec 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -106,8 +106,12 @@ async function getDirectOrganizationRole( // takeover/reconcile retry reads the membership back FIRST: an already-applied // role or an already-removed member settles completed and replays, which // avoids the NOT_FOUND trap of re-running the helper on the already-applied -// state. The membership helpers and the `dailyUsageLimitUsd` path are -// unchanged. +// state. For member removal the ledger admission runs BEFORE the +// missing-membership precondition: a retry of an already-removed member must +// reach the settled replay or takeover repair instead of being rejected with +// NOT_FOUND first; the permission and bot checks are retained for a +// first-time removal. The membership helpers and the `dailyUsageLimitUsd` +// path are unchanged. const ORG_LEDGER_DOMAIN = 'organization' as const; /** The in-flight window: while an `admitted` row holds a live lease, same-key @@ -449,6 +453,26 @@ async function completeOrgMemberRemoval(args: { return successResult({ updated: args.memberId }); } +/** Best-effort failed settle for a member-removal row; never masks the typed rejection. */ +async function settleOrgMemberRemovalFailed(args: { + row: OperationLedgerRow; + distinctId: string; + outcomeCode: string; +}): Promise { + await bestEffortOrgLedgerWrite(() => + settleOperation(db, { + rowId: args.row.id, + status: 'failed', + outcomeCode: args.outcomeCode, + outboxEvent: orgSettledOutboxEvent({ + distinctId: args.distinctId, + intent: 'member_remove', + outcome: 'failed', + }), + }) + ); +} + /** * Runs the removal helper under an already-admitted row. A `rowCount` of zero * means the member was already gone (never satisfied under the `admitted` @@ -463,18 +487,11 @@ async function executeOrgMemberRemove(args: { const distinctId = args.user.google_user_email || args.user.id; const result = await removeUserFromOrganization(args.organizationId, args.memberId, args.user.id); if (result.rowCount === 0) { - await bestEffortOrgLedgerWrite(() => - settleOperation(db, { - rowId: args.row.id, - status: 'failed', - outcomeCode: 'member_absent', - outboxEvent: orgSettledOutboxEvent({ - distinctId, - intent: 'member_remove', - outcome: 'failed', - }), - }) - ); + await settleOrgMemberRemovalFailed({ + row: args.row, + distinctId, + outcomeCode: 'member_absent', + }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Failed to remove user from organization', @@ -785,38 +802,39 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Get the target user's role and bot status - const [targetMember] = await db - .select({ - role: organization_memberships.role, - isBot: kilocode_users.is_bot, - }) - .from(organization_memberships) - .innerJoin(kilocode_users, eq(kilocode_users.id, organization_memberships.kilo_user_id)) - .where( - and( - eq(organization_memberships.organization_id, organizationId), - eq(organization_memberships.kilo_user_id, memberId) - ) - ); + // Without an operationKey, use the existing path exactly: the target + // membership lookup (missing-member NOT_FOUND + bot FORBIDDEN) runs + // before the helper and no ledger row is written. + if (operationKey === undefined) { + const [targetMember] = await db + .select({ + role: organization_memberships.role, + isBot: kilocode_users.is_bot, + }) + .from(organization_memberships) + .innerJoin(kilocode_users, eq(kilocode_users.id, organization_memberships.kilo_user_id)) + .where( + and( + eq(organization_memberships.organization_id, organizationId), + eq(organization_memberships.kilo_user_id, memberId) + ) + ); - if (!targetMember) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'User is not a member of this organization', - }); - } + if (!targetMember) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User is not a member of this organization', + }); + } - // Prevent removal of bot users - if (targetMember.isBot) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Service account users cannot be removed', - }); - } + // Prevent removal of bot users + if (targetMember.isBot) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Service account users cannot be removed', + }); + } - // Without an operationKey, use the existing path exactly. - if (operationKey === undefined) { const result = await removeUserFromOrganization(organizationId, memberId, user.id); if (result.rowCount === 0) { throw new TRPCError({ @@ -841,8 +859,12 @@ export const organizationsMembersRouter = createTRPCRouter({ return successResult({ updated: memberId }); } - // With an operationKey, admit a ledger row before running the helper - // (P1-A-08e). + // With an operationKey, admit a ledger row BEFORE the missing-membership + // precondition (P1-A-08e). A same-key retry after a lost response must + // be able to replay a settled row or enter takeover repair when the + // member is already removed; the precondition would otherwise reject the + // retry with NOT_FOUND before the ledger could repair it. The permission + // and bot checks are retained for a first-time (`admitted`) removal. const resourceKey = orgMemberRemoveResourceKey(organizationId, memberId); const admission = await admitOperation(db, { userId: user.id, @@ -858,13 +880,58 @@ export const organizationsMembersRouter = createTRPCRouter({ throw orgOperationKeyReuseMismatchError(); } switch (admission.admission) { - case 'admitted': + case 'admitted': { + const distinctId = user.google_user_email || user.id; + const [targetMember] = await db + .select({ + role: organization_memberships.role, + isBot: kilocode_users.is_bot, + }) + .from(organization_memberships) + .innerJoin(kilocode_users, eq(kilocode_users.id, organization_memberships.kilo_user_id)) + .where( + and( + eq(organization_memberships.organization_id, organizationId), + eq(organization_memberships.kilo_user_id, memberId) + ) + ); + + // A first-time removal of a member that is already gone settles the + // row `failed` so a later same-key retry replays the failure instead + // of taking over into the removal helper. + if (!targetMember) { + await settleOrgMemberRemovalFailed({ + row: admission.row, + distinctId, + outcomeCode: 'member_absent', + }); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User is not a member of this organization', + }); + } + + // Prevent removal of bot users; the refusal settles the row `failed` + // so a later same-key retry cannot take over past the bot check. + if (targetMember.isBot) { + await settleOrgMemberRemovalFailed({ + row: admission.row, + distinctId, + outcomeCode: 'bot_removal_refused', + }); + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Service account users cannot be removed', + }); + } + return executeOrgMemberRemove({ row: admission.row, user, organizationId, memberId, }); + } case 'takeover': case 'duplicate_reconcile_pending': return repairOrgMemberRemove({ From eee114469d6bcb0de8c9dee349c016941b75b702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 12:23:19 +0200 Subject: [PATCH 18/56] fix(mobile): preserve remote retry keys --- .../agents/use-new-session-creator.test.ts | 48 ++++++++++++++++++- .../agents/use-new-session-creator.ts | 16 +++++-- .../agents/use-remote-spawn-dispatch.test.ts | 33 +++++++++++++ .../hooks/remote-instance-spawn-classifier.ts | 32 +++++++++---- .../hooks/use-remote-instance-spawn.test.ts | 18 +++++++ .../src/create-session.test.ts | 33 +++++++++++++ .../cloud-agent-sdk/src/create-session.ts | 33 ++++++++++--- 7 files changed, 194 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts index abb343da62..38e05c9b3e 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.test.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -64,6 +64,11 @@ vi.mock('expo-crypto', () => { import { useNewSessionCreator } from './use-new-session-creator'; +// Simulated attachment wire payload (`{path, files}`). Each test sets this +// before a submit; the fake `toWirePayload` below reads it at call time so a +// test can change attachments between two submits. +let attachmentsWire: { path: string; files: string[] } | null = null; + function creationInProgressError(): Error { return Object.assign(new Error('creation_in_progress'), { data: { code: 'CONFLICT' } }); } @@ -119,7 +124,7 @@ function runCreator(args: { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- attachment fake shape, never read by the create path attachments: { attachments: [], - toWirePayload: () => null, + toWirePayload: () => attachmentsWire, } as never, mode: (args.mode ?? 'code') as never, model: args.model ?? 'model-1', @@ -147,6 +152,7 @@ describe('useNewSessionCreator operationKey', () => { routerPush.mockClear(); navigationDispatch.mockClear(); toastError.mockClear(); + attachmentsWire = null; }); it('keeps the same operationKey across retryable creation_in_progress failures', async () => { @@ -220,4 +226,44 @@ describe('useNewSessionCreator operationKey', () => { const keys = usedOperationKeys(); expect(keys[1]).not.toBe(keys[0]); }); + + it('keeps the same operationKey across retryable failures when attachments are unchanged', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + attachmentsWire = { path: 'p-1', files: ['a-1'] }; + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + // The wire payload the fingerprint read is the payload the create body + // carries, so the fingerprint and the mutation agree on the intent. + expect(prepareSessionMutate.mock.calls[0]?.[0]).toMatchObject({ + attachments: { path: 'p-1', files: ['a-1'] }, + }); + }); + + it('treats changed attachments as a new intent with a new key', async () => { + prepareSessionMutate + .mockRejectedValueOnce(creationInProgressError()) + .mockRejectedValueOnce(creationInProgressError()); + const creator = runCreator({}); + attachmentsWire = { path: 'p-1', files: ['a-1'] }; + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + // The user swapped the attachment; the next submit is a fresh intent + // with a fresh key, otherwise the same-key retry would replay the + // previous intent's ledger result instead of creating with the new file. + attachmentsWire = { path: 'p-1', files: ['a-2'] }; + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[1]).not.toBe(keys[0]); + }); }); diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index bb92c74b6c..0a3150b2eb 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -79,6 +79,16 @@ export function useNewSessionCreator({ setIsCreating(true); + // The wire payload is the exact attachment input the create carries (the + // upload path plus every uploaded remote filename). Include it in the + // intent fingerprint so a changed attachment set becomes a fresh intent + // with a fresh key — otherwise a same-key retry after the user swapped + // files would replay the previous intent's ledger result. Computed once, + // before the fingerprint, and reused for the create body so the two + // cannot disagree. At submit time the screen has already gated on + // `attachments.isUploading` / `attachments.hasFailedAttachments`, so the + // payload is stable across retries of the same intent. + const attachmentWire = attachments.toWirePayload(); const intentFingerprint = JSON.stringify({ prompt, mode, @@ -86,6 +96,7 @@ export function useNewSessionCreator({ variant: variant || undefined, repo: selectedRepo, organizationId: organizationId ?? null, + attachments: attachmentWire ?? null, }); const operationKey = getKey(intentFingerprint); @@ -113,9 +124,8 @@ export function useNewSessionCreator({ autoInitiate: true, operationKey, }; - const wireAttachments = attachments.toWirePayload(); - if (wireAttachments) { - baseInput.attachments = wireAttachments; + if (attachmentWire) { + baseInput.attachments = attachmentWire; } const result = organizationId diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts index 27337fac3a..8b6a9f8ef3 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts @@ -9,6 +9,7 @@ import { type SharePayload, } from '@/lib/share-payload'; import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import { UserWebCommandError } from '@kilocode/cloud-agent-sdk/user-web-connection'; import { buildCreateRemoteSessionInput, type CreateSessionOutcome, @@ -400,6 +401,38 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(second?.operationKey).toBe(first?.operationKey); }); + it('keeps the same operationKey for a COMMAND_ALREADY_PENDING in-flight dedupe', async () => { + // The relay rejects a same-key duplicate while the command is in flight + // with this structured error. The classifier maps it to `retryable`, so + // the dispatch must KEEP the key: a rotation would mint a new mutation + // identity and let the relay dispatch a second command instead of + // replaying the durable terminal result under the same identity. + const alreadyPending = { + status: 'retryable' as const, + reason: 'Command is already in flight', + cause: new UserWebCommandError({ + code: 'COMMAND_ALREADY_PENDING', + message: 'Command is already in flight', + }), + }; + spawnMock.mockResolvedValueOnce(alreadyPending).mockResolvedValueOnce(alreadyPending); + const { onStart } = runHookWithProvider({ organizationId: 'org-xyz', withProvider: false }); + + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + const first = spawnMock.mock.calls[0]?.[2] as { operationKey?: string } | undefined; + const second = spawnMock.mock.calls[1]?.[2] as { operationKey?: string } | undefined; + expect(first?.operationKey).toBeDefined(); + expect(second?.operationKey).toBe(first?.operationKey); + }); + it('rotates the operationKey after a ready outcome', async () => { const retryable = { status: 'retryable' as const, diff --git a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts index bd363386d7..f60f02cc02 100644 --- a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts +++ b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts @@ -21,6 +21,7 @@ import { UserWebCommandError, } from '@kilocode/cloud-agent-sdk/user-web-connection'; import { + COMMAND_ALREADY_PENDING_CODE, type CreateRemoteSessionInput, createRemoteSessionOnConnection, parseCreateSessionResponse, @@ -38,15 +39,18 @@ export type { CreateRemoteSessionInput }; * the caller needs: * * - `ready` — a fresh `KiloSessionId` was provisioned by the CLI - * - `retryable` — either a transport-level failure (timeout, destroyed - * connection, socket gone) OR the DO-emitted literal - * `'Session owner not found'`, which is semantically - * "the instance disconnected" and should follow the - * same recovery path as a transport failure + * - `retryable` — a transport-level failure (timeout, destroyed + * connection, socket gone), the DO-emitted literal + * `'Session owner not found'` (semantically "the + * instance disconnected", same recovery path as a + * transport failure), OR the relay's + * `COMMAND_ALREADY_PENDING` same-key in-flight dedupe + * (the intent is still pending; keep the operation key + * and wait for the durable replay) * - `nonRetryable` — anything else: a malformed response envelope, a * delivered CLI string error (e.g. `'failed to create - * session'`), or any structured `UserWebCommandError` - * (including `CLI_UPGRADE_REQUIRED`) + * session'`), or any other structured + * `UserWebCommandError` (including `CLI_UPGRADE_REQUIRED`) * * Note on intentionally-unreachable structured codes: relay-sourced codes * that are semantically transient (`COMMAND_EXPIRED`, `PENDING_COMMAND_LIMIT`) @@ -96,9 +100,19 @@ export function classifyCreateSessionResult( // result.status === 'rejected' const cause: unknown = result.reason; - // Structured relay error: keep `.code` available; the classifier still - // intentionally maps all such errors to `nonRetryable` (see header). + // Structured relay error: keep `.code` available; every code maps to + // `nonRetryable` EXCEPT `COMMAND_ALREADY_PENDING`, the relay's same-key + // in-flight dedupe marker. That marker means the intent is still pending + // on the DO, so the caller must keep its operation key and retry to get + // the durable replay (see `COMMAND_ALREADY_PENDING_CODE`). if (cause instanceof UserWebCommandError) { + if (cause.code === COMMAND_ALREADY_PENDING_CODE) { + return { + status: 'retryable', + reason: cause.message || cause.code, + cause, + }; + } return { status: 'nonRetryable', reason: cause.message || cause.code, diff --git a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts index 8c934f782c..0ae9a0b04c 100644 --- a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts +++ b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts @@ -87,6 +87,24 @@ describe('classifyCreateSessionResult', () => { } }); + it('returns retryable for a structured COMMAND_ALREADY_PENDING same-key in-flight dedupe', () => { + // The relay emits this code when a same-mutationId duplicate arrives while + // the command is in flight or its durable entry is still pending. The + // intent is NOT terminal: the retry must keep the operation key so the DO + // replays the durable terminal result instead of dispatching a second + // command under a new mutation identity. + const cause = new UserWebCommandError({ + code: 'COMMAND_ALREADY_PENDING', + message: 'Command is already in flight', + }); + const result = classifyCreateSessionResult({ status: 'rejected', reason: cause }); + expect(result).toEqual({ + status: 'retryable', + reason: 'Command is already in flight', + cause, + }); + }); + it('returns nonRetryable for any other structured UserWebCommandError code', () => { const cause = new UserWebCommandError({ code: 'SESSION_OWNER_CHANGED', diff --git a/packages/cloud-agent-sdk/src/create-session.test.ts b/packages/cloud-agent-sdk/src/create-session.test.ts index 55797f1523..bde820284e 100644 --- a/packages/cloud-agent-sdk/src/create-session.test.ts +++ b/packages/cloud-agent-sdk/src/create-session.test.ts @@ -446,3 +446,36 @@ describe('createRemoteSessionOnConnection', () => { expect(replayedOutcome).toEqual({ status: 'ready', sessionID: VALID_SESSION_ID }); }); }); + +describe('classifyCreateSessionResult', () => { + it('returns retryable for a COMMAND_ALREADY_PENDING same-key in-flight dedupe', () => { + // The relay emits this structured code when a same-mutationId duplicate + // arrives while the command is in flight or its durable entry is pending. + // The intent is NOT terminal: the retry must keep the operation key so + // the DO replays the durable terminal result instead of dispatching a + // second command (which a key rotation's new mutation identity would do). + const cause = new UserWebCommandError({ + code: 'COMMAND_ALREADY_PENDING', + message: 'Command is already in flight', + }); + const outcome = classifyCreateSessionResult({ status: 'rejected', reason: cause }); + expect(outcome).toEqual({ + status: 'retryable', + reason: 'Command is already in flight', + cause, + }); + }); + + it('returns nonRetryable for a structured UserWebCommandError with any other code', () => { + const cause = new UserWebCommandError({ + code: 'SESSION_OWNER_CHANGED', + message: 'Session owner changed', + }); + const outcome = classifyCreateSessionResult({ status: 'rejected', reason: cause }); + expect(outcome).toEqual({ + status: 'nonRetryable', + reason: 'Session owner changed', + cause, + }); + }); +}); diff --git a/packages/cloud-agent-sdk/src/create-session.ts b/packages/cloud-agent-sdk/src/create-session.ts index 8ddaec9e1d..3947750b60 100644 --- a/packages/cloud-agent-sdk/src/create-session.ts +++ b/packages/cloud-agent-sdk/src/create-session.ts @@ -149,16 +149,27 @@ export type CreateSessionOutcome = | { status: 'retryable'; reason: string; cause: unknown } | { status: 'nonRetryable'; reason: string; cause: unknown }; +/** + * Relay-emitted structured code (`UserConnectionDO`) for a same-key duplicate + * whose command is already in flight or whose durable entry is still pending. + * Semantically the intent is NOT terminal: the retry must keep the caller's + * operation key so it rides the same durable identity and the DO replays the + * stored terminal result instead of dispatching a second command. Classified + * as `retryable`, never `nonRetryable`. + */ +export const COMMAND_ALREADY_PENDING_CODE = 'COMMAND_ALREADY_PENDING'; + /** * Classify the resolved-or-rejected outcome of `createRemoteSessionOnConnection` * into the spawn flow's state space: * * - `ready` — a fresh `KiloSessionId` was provisioned by the CLI - * - `retryable` — either a transport-level failure (timeout, destroyed - * connection, socket gone) OR the relay-emitted literal - * `'Session owner not found'` + * - `retryable` — a transport-level failure (timeout, destroyed + * connection, socket gone), the relay-emitted literal + * `'Session owner not found'`, or the relay's + * `COMMAND_ALREADY_PENDING` same-key in-flight dedupe * - `nonRetryable` — anything else: a malformed response envelope, a - * delivered CLI string error, or any structured + * delivered CLI string error, or any other structured * `UserWebCommandError` * * A durable D8 replay of a terminal envelope carries exactly the live @@ -186,9 +197,19 @@ export function classifyCreateSessionResult( // result.status === 'rejected' const cause: unknown = result.reason; - // Structured relay error: keep `.code` available; the classifier still - // intentionally maps all such errors to `nonRetryable`. + // Structured relay error: keep `.code` available; every code maps to + // `nonRetryable` EXCEPT `COMMAND_ALREADY_PENDING`, the relay's same-key + // in-flight dedupe marker. That marker means the intent is still pending + // on the DO, so the caller must keep its operation key and retry to get + // the durable replay (see `COMMAND_ALREADY_PENDING_CODE`). if (cause instanceof UserWebCommandError) { + if (cause.code === COMMAND_ALREADY_PENDING_CODE) { + return { + status: 'retryable', + reason: cause.message || cause.code, + cause, + }; + } return { status: 'nonRetryable', reason: cause.message || cause.code, From 43d178f21aa0e411e177af6fb01b1d99fd108950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 12:42:44 +0200 Subject: [PATCH 19/56] fix(cloud): preserve fallback and settlement recovery --- .../src/create-session.test.ts | 75 ++++++++++-- .../cloud-agent-sdk/src/create-session.ts | 73 +++++++++++- .../src/session/session-prepare.test.ts | 77 +++++++++++++ .../src/session/session-registration.ts | 109 ++++++++++++------ 4 files changed, 291 insertions(+), 43 deletions(-) diff --git a/packages/cloud-agent-sdk/src/create-session.test.ts b/packages/cloud-agent-sdk/src/create-session.test.ts index bde820284e..b6f6ca1b99 100644 --- a/packages/cloud-agent-sdk/src/create-session.test.ts +++ b/packages/cloud-agent-sdk/src/create-session.test.ts @@ -376,35 +376,94 @@ describe('createRemoteSessionOnConnection', () => { }); }); - it('keeps the extended and bare identities stable and distinct across attempts', async () => { + it('regression: later attempts with the same logical key use the proven bare identity and never replay the stale extended error', async () => { + // The relay stores the delivered `invalid create_session command` error + // under `${key}:ext` forever, so every later attempt under that identity + // would replay the stale error instead of reaching the CLI. After the + // first ext→bare fallback succeeds, attempts 2 and 3 must ride the proven + // `${key}:bare` identity directly (the DO replays its terminal success). const connection = makeFakeConnection(); connection.sendCommandToConnection + // Attempt 1: the extended attempt is rejected by the old CLI... .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + // ...and the bare fallback provisions the session. .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }) - .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) - .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + // Attempts 2 and 3: the DO replays the terminal bare success envelope. + .mockResolvedValue({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); - await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + const first = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { mutationId: 'spawn-key-1', agent: 'code', }); - await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + const second = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + const third = await createRemoteSessionOnConnection(connection, 'cli-owner-1', { mutationId: 'spawn-key-1', agent: 'code', }); + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(4); const mutationIds = connection.sendCommandToConnection.mock.calls.map( call => (call[0] as { mutationId?: string }).mutationId ); - // Same key, same attempt → identical wire identity across calls. + // Exactly one extended attempt; every later attempt rides the proven bare + // identity, so the stale extended error is never replayed. expect(mutationIds).toEqual([ 'spawn-key-1:ext', 'spawn-key-1:bare', - 'spawn-key-1:ext', + 'spawn-key-1:bare', 'spawn-key-1:bare', ]); - // The two durable identities must never collide. expect(new Set(mutationIds).size).toBe(2); + for (const call of connection.sendCommandToConnection.mock.calls.slice(1)) { + expect(call[0]).toMatchObject({ + command: 'create_session', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + mutationId: 'spawn-key-1:bare', + }); + } + for (const result of [first, second, third]) { + expect(parseCreateSessionResponse(result)).toEqual({ + ok: true, + kiloSessionId: VALID_SESSION_ID, + }); + } + }); + + it('tries the extended identity again for a fresh connection with the same logical key', async () => { + // The dead-identity memory is per connection: a brand-new connection owns + // a separate UserConnectionDO, so its `${key}:ext` identity is not durably + // poisoned and the extended attempt must be tried again. + const first = makeFakeConnection(); + first.sendCommandToConnection + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + await createRemoteSessionOnConnection(first, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + const second = makeFakeConnection(); + second.sendCommandToConnection + .mockRejectedValueOnce(new CommandDeliveredError('invalid create_session command')) + .mockResolvedValueOnce({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + const result = await createRemoteSessionOnConnection(second, 'cli-owner-1', { + mutationId: 'spawn-key-1', + agent: 'code', + }); + + expect(second.sendCommandToConnection).toHaveBeenCalledTimes(2); + const mutationIds = second.sendCommandToConnection.mock.calls.map( + call => (call[0] as { mutationId?: string }).mutationId + ); + expect(mutationIds).toEqual(['spawn-key-1:ext', 'spawn-key-1:bare']); + expect(parseCreateSessionResponse(result)).toEqual({ + ok: true, + kiloSessionId: VALID_SESSION_ID, + }); }); it('omits mutationId on both attempts when the caller provides none', async () => { diff --git a/packages/cloud-agent-sdk/src/create-session.ts b/packages/cloud-agent-sdk/src/create-session.ts index 3947750b60..3733478ca9 100644 --- a/packages/cloud-agent-sdk/src/create-session.ts +++ b/packages/cloud-agent-sdk/src/create-session.ts @@ -74,6 +74,47 @@ function attemptMutationId(key: string | undefined, suffix: string): string | un return key !== undefined ? `${key}${suffix}` : undefined; } +/** + * Connections whose extended (`${key}:ext`) durable identity already delivered + * the old-CLI `invalid create_session command` terminal error. The relay stores + * that delivered error under the extended identity, so every later attempt + * under `${key}:ext` replays the stale error instead of reaching the CLI. Once + * the `${key}:bare` fallback has succeeded, later creates with the same logical + * key on the same connection skip the dead extended identity and use the proven + * bare identity directly. Keyed by connection object (each connection owns a + * separate UserConnectionDO) and composite on `connectionId` + logical key, so + * a fresh connection is never short-circuited. + */ +const bareFallbackProvenKeys = new WeakMap>(); + +/** Composite key for one connection's durable extended identity. */ +function extendedIdentityKey(connectionId: string, logicalKey: string): string { + return `${connectionId}:${logicalKey}`; +} + +/** Remembers that the connection's `${logicalKey}:ext` identity is dead. */ +function markExtendedIdentityDead( + connection: object, + connectionId: string, + logicalKey: string +): void { + const keys = bareFallbackProvenKeys.get(connection) ?? new Set(); + keys.add(extendedIdentityKey(connectionId, logicalKey)); + bareFallbackProvenKeys.set(connection, keys); +} + +/** True when the connection already proved the `${logicalKey}:ext` identity dead. */ +function isExtendedIdentityDead( + connection: object, + connectionId: string, + logicalKey: string +): boolean { + return ( + bareFallbackProvenKeys.get(connection)?.has(extendedIdentityKey(connectionId, logicalKey)) ?? + false + ); +} + /** * Connection-scoped `create_session` for the `kilo remote` process-per-session * spawn flow. Unlike the session-scoped `createSession` in @@ -88,6 +129,12 @@ function attemptMutationId(key: string | undefined, suffix: string): string | un * (see `attemptMutationId`). When omitted, the wire carries no mutationId and * the relay falls back to a per-wire random correlation id. * + * Once an extended attempt delivers the old-CLI `invalid create_session + * command` error, the relay keeps that terminal error under `${key}:ext` + * forever. Later creates with the same logical key on the same connection + * therefore skip the dead extended identity and use the proven `${key}:bare` + * identity directly, so a retry never replays the stale extended error. + * * The returned promise resolves with the raw reply; the caller is responsible * for parsing the response shape. A delivered error response (string or * structured `UserWebCommandError`) rejects the promise; transport failures @@ -108,8 +155,24 @@ export async function createRemoteSessionOnConnection( }; const hasExtendedFields = data.agent !== undefined || data.model !== undefined || data.orgId !== undefined; - const extendedMutationId = attemptMutationId(input?.mutationId, EXTENDED_MUTATION_ID_SUFFIX); + const logicalKey = input?.mutationId; + // A prior extended attempt for this connection and logical key already + // delivered the old-CLI invalid-command terminal error, which the relay + // stores under `${key}:ext` and would replay on every later attempt. Skip + // the dead identity and use the proven `${key}:bare` identity directly. + const extendedIdentityIsDead = + logicalKey !== undefined && isExtendedIdentityDead(connection, connectionId, logicalKey); + const extendedMutationId = attemptMutationId(logicalKey, EXTENDED_MUTATION_ID_SUFFIX); try { + if (extendedIdentityIsDead) { + const bareMutationId = attemptMutationId(logicalKey, BARE_MUTATION_ID_SUFFIX); + return await connection.sendCommandToConnection({ + command: 'create_session', + data: { protocolVersion: 1 }, + expectedConnectionId: connectionId, + ...(bareMutationId !== undefined ? { mutationId: bareMutationId } : {}), + }); + } return await connection.sendCommandToConnection({ command: 'create_session', data, @@ -124,7 +187,13 @@ export async function createRemoteSessionOnConnection( error instanceof CommandDeliveredError && error.message === INVALID_CREATE_SESSION_COMMAND ) { - const bareMutationId = attemptMutationId(input?.mutationId, BARE_MUTATION_ID_SUFFIX); + // The extended identity is now durably dead (the relay stored the + // invalid-command error under it): remember it so later retries with the + // same logical key skip straight to the proven bare identity. + if (logicalKey !== undefined) { + markExtendedIdentityDead(connection, connectionId, logicalKey); + } + const bareMutationId = attemptMutationId(logicalKey, BARE_MUTATION_ID_SUFFIX); return connection.sendCommandToConnection({ command: 'create_session', data: { protocolVersion: 1 }, diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 4023529f04..2a3cbb2af9 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -547,6 +547,40 @@ describe('createSessionWithLedger admission ladder', () => { ); }); + it('surfaces a typed retryable internal error when the completed settle fails after a successful DO registration', async () => { + // The DO registered the session and admitted the initial turn, but the + // terminal ledger settle failed. The create must NOT return success while + // the row stays non-terminal: it surfaces a typed retryable internal error + // and the recorded canonical IDs let the next same-key retry reconcile. + settleOperationMock.mockRejectedValueOnce(new Error('ledger db unavailable')); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + + await expect( + createSessionWithLedger(makeRequest({ options: { operationKey: OPERATION_KEY } }), ctx, { + operationKey: OPERATION_KEY, + startedAt: 1_700_000_000_000, + }) + ).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'session_creation_settle_failed', + cause: expect.objectContaining({ + error: 'SESSION_CREATE_SETTLE_FAILED', + retryable: true, + }), + }); + + // The create effect ran; the failure is the terminal settle, not the DO. + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + // Canonical IDs were recorded before the DO call, so the next same-key + // retry reconciles them instead of allocating a second session. + expect(recordOperationProgressMock).toHaveBeenCalledWith(expect.any(Object), ROW_ID, { + cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + initialMessageId: INITIAL_MESSAGE_ID, + }); + }); + it('replays the settled create for a duplicate_settled admission', async () => { admitOperationMock.mockResolvedValueOnce({ admission: 'duplicate_settled', @@ -886,6 +920,49 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { }); }); + it('surfaces a typed retryable internal error instead of replaying when the reconcile settle fails', async () => { + // The authoritative reconcile proved the session live and the initial + // message admitted, but the terminal ledger settle failed. The retry must + // NOT replay success while the row stays non-terminal: it surfaces the + // typed retryable internal error, and the row keeps its canonical IDs for + // the next reconcile. + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ canonical_result: canonicalIds }), + }); + getPgDbMock.mockReturnValue( + makeDb([[{ sessionId: KILO_SESSION_ID }], [{ email: 'test@example.com' }]]) + ); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + settleOperationMock.mockRejectedValueOnce(new Error('ledger db unavailable')); + + await expect( + createSessionWithLedger( + makeRequest({ options: { operationKey: OPERATION_KEY } }), + ctx, + takeoverOptions + ) + ).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'session_creation_settle_failed', + cause: expect.objectContaining({ + error: 'SESSION_CREATE_SETTLE_FAILED', + retryable: true, + }), + }); + + // The reconcile read the DO state and attempted the completed settle, but + // the create effect was never re-run. + expect(doStub.getMetadata).toHaveBeenCalledTimes(1); + expect(doStub.getMessageResult).toHaveBeenCalledWith(INITIAL_MESSAGE_ID); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ rowId: ROW_ID, status: 'completed', outcomeCode: 'ok' }) + ); + }); + it('returns CONFLICT when the recorded initial message is not admitted', async () => { admitOperationMock.mockResolvedValueOnce({ admission: 'takeover', diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index e8a414a589..cb7b042edf 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -24,6 +24,7 @@ import { recordOperationProgress, settleOperation, type OutboxEventInput, + type SettleOperationInput, } from '@kilocode/db/operation-ledger'; import type { OperationLedgerRow } from '@kilocode/db/schema'; @@ -213,6 +214,52 @@ function creationInProgressError(): TRPCError { return new TRPCError({ code: 'CONFLICT', message: 'creation_in_progress' }); } +/** Typed client code for a terminal ledger settle failure after a confirmed success. */ +const SESSION_CREATE_SETTLE_FAILED_CODE = 'SESSION_CREATE_SETTLE_FAILED'; + +/** Stable message for the typed retryable internal settle-failure error. */ +const SESSION_CREATE_SETTLE_FAILED_MESSAGE = 'session_creation_settle_failed'; + +/** + * Typed retryable internal error for a terminal `completed` settle failure + * after the create effect succeeded (DO registration + initial admission, or + * an authoritative reconcile). The ledger row stays non-terminal with the + * canonical IDs recorded by progress, so the same-key retry ladder must + * reconcile it — the caller must never report success or replay while the row + * is not terminal. Thrown as a TRPCError so the router's error formatter + * projects the typed retryable client error. + */ +function ledgerSettleFailureError(cause: unknown): TRPCError { + logger + .withFields({ error: cause instanceof Error ? cause.message : String(cause) }) + .error('Failed to settle session create operation ledger row after a confirmed success'); + return new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: SESSION_CREATE_SETTLE_FAILED_MESSAGE, + cause: { + error: SESSION_CREATE_SETTLE_FAILED_CODE, + message: SESSION_CREATE_SETTLE_FAILED_MESSAGE, + retryable: true, + }, + }); +} + +/** + * Terminal `completed` settle for a confirmed-success create. Unlike the + * best-effort failure hooks, a settle failure here must not be swallowed: + * the row would stay non-terminal while the caller reports success. It + * surfaces the typed retryable internal error instead; the canonical IDs + * already recorded by progress keep the same-key retry on the reconcile + * ladder. + */ +async function settleConfirmedSuccess(db: WorkerDb, input: SettleOperationInput): Promise { + try { + await settleOperation(db, input); + } catch (error) { + throw ledgerSettleFailureError(error); + } +} + /** * Best-effort ledger write: a failure is logged and never masks the primary * creation outcome. The row then stays `admitted`/`reconcile_pending` and the @@ -764,24 +811,22 @@ async function executeLedgerCreate( }) ), onSuccess: result => - bestEffortLedgerWrite(() => - settleOperation(db, { - rowId: row.id, - status: 'completed', - outcomeCode: 'ok', - canonicalResult: { - cloudAgentSessionId: result.cloudAgentSessionId, - kiloSessionId: result.kiloSessionId, - }, - outboxEvent: sessionCreateSettledOutboxEvent({ - distinctId, - outcome: 'completed', - admission: admissionKind, - startedAt: options.startedAt, - inOrganization, - }), - }) - ), + settleConfirmedSuccess(db, { + rowId: row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { + cloudAgentSessionId: result.cloudAgentSessionId, + kiloSessionId: result.kiloSessionId, + }, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'completed', + admission: admissionKind, + startedAt: options.startedAt, + inOrganization, + }), + }), }; const result = await startNewSession(input, ctx, { billingOrigin: options.billingOrigin }, hooks); @@ -953,20 +998,18 @@ async function confirmInitialMessageAdmitted( } const distinctId = await resolveSessionCreateDistinctId(db, ctx.userId); - await bestEffortLedgerWrite(() => - settleOperation(db, { - rowId: row.id, - status: 'completed', - outcomeCode: 'ok', - canonicalResult: { cloudAgentSessionId, kiloSessionId }, - outboxEvent: sessionCreateSettledOutboxEvent({ - distinctId, - outcome: 'completed', - admission: 'takeover', - startedAt: options.startedAt, - inOrganization: input.options?.kilocodeOrganizationId != null, - }), - }) - ); + await settleConfirmedSuccess(db, { + rowId: row.id, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { cloudAgentSessionId, kiloSessionId }, + outboxEvent: sessionCreateSettledOutboxEvent({ + distinctId, + outcome: 'completed', + admission: 'takeover', + startedAt: options.startedAt, + inOrganization: input.options?.kilocodeOrganizationId != null, + }), + }); return { cloudAgentSessionId, kiloSessionId, replayed: true }; } From 32e8065b62e9ed73de15083bd96844d6cefccfe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 6 Aug 2026 13:14:21 +0200 Subject: [PATCH 20/56] fix(security): retain terminal settlement and block retry --- .../agents/use-new-session-creator.test.ts | 74 ++++++- .../dismiss-finding-screen.mounted.test.tsx | 204 ++++++++++++++++++ .../security-agent/dismiss-finding-screen.tsx | 9 +- services/security-sync/src/index.test.ts | 86 +++++++- services/security-sync/src/index.ts | 9 +- 5 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/use-new-session-creator.test.ts b/apps/mobile/src/components/agents/use-new-session-creator.test.ts index 38e05c9b3e..27bff65027 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.test.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.test.ts @@ -45,13 +45,32 @@ vi.mock('@/lib/agent-session-cache', () => ({ invalidateAgentSessionQueries: vi.fn(), })); // The real classifier lives in mobile-session-manager (covered by its own -// suite); this test only needs the retryable/non-retryable split. -vi.mock('@/components/agents/mobile-session-manager', () => ({ - isCloudPrepareRetryableError: (error: unknown) => { - const record = error as { data?: { code?: string }; message?: string }; - return record.data?.code === 'CONFLICT' && record.message === 'creation_in_progress'; - }, -})); +// suite); this test mirrors its decision so hook-level retries are exercised +// for every retryable shape: no code (transport), CONFLICT + +// `creation_in_progress`, and the transient 5xx-class codes. +vi.mock('@/components/agents/mobile-session-manager', () => { + const TRANSIENT_CODES = new Set([ + 'INTERNAL_SERVER_ERROR', + 'BAD_GATEWAY', + 'SERVICE_UNAVAILABLE', + 'GATEWAY_TIMEOUT', + 'TIMEOUT', + 'TOO_MANY_REQUESTS', + ]); + return { + isCloudPrepareRetryableError: (error: unknown) => { + const record = error as { data?: { code?: string }; code?: string; message?: string }; + const code = record.data?.code ?? record.code; + if (code === undefined) { + return true; + } + if (code === 'CONFLICT') { + return record.message === 'creation_in_progress'; + } + return TRANSIENT_CODES.has(code); + }, + }; +}); vi.mock('expo-crypto', () => { let n = 0; return { @@ -77,6 +96,14 @@ function badRequestError(): Error { return Object.assign(new Error('session_creation_failed'), { data: { code: 'BAD_REQUEST' } }); } +function transportError(): Error { + return new Error('Network request failed'); +} + +function transient5xxError(): Error { + return Object.assign(new Error('service unavailable'), { data: { code: 'SERVICE_UNAVAILABLE' } }); +} + type ReactInternals = { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { H: unknown; @@ -197,6 +224,39 @@ describe('useNewSessionCreator operationKey', () => { expect(keys[2]).not.toBe(keys[0]); }); + it('keeps the same operationKey across a plain transport failure', async () => { + prepareSessionMutate + .mockRejectedValueOnce(transportError()) + .mockRejectedValueOnce(transportError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + // A transport failure is ambiguous: the ledger may have accepted the + // attempt, so the same-key retry lets it reconcile instead of spawning a + // second session. + expect(keys[1]).toBe(keys[0]); + }); + + it('keeps the same operationKey across a transient typed 5xx failure', async () => { + prepareSessionMutate + .mockRejectedValueOnce(transient5xxError()) + .mockRejectedValueOnce(transient5xxError()); + const creator = runCreator({}); + + creator.promptRef.current = 'hello'; + await creator.createSessionFromDraft(); + await creator.createSessionFromDraft(); + + const keys = usedOperationKeys(); + expect(keys[0]).toBeDefined(); + expect(keys[1]).toBe(keys[0]); + }); + it('rotates the operationKey after a typed non-retryable rejection', async () => { prepareSessionMutate .mockRejectedValueOnce(badRequestError()) diff --git a/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx new file mode 100644 index 0000000000..58175110db --- /dev/null +++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx @@ -0,0 +1,204 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer for RN trees under vitest (node env, no jsdom); its React 19 deprecation notice points to the DOM-based Testing Library, which cannot render this app's non-DOM tree. */ + +// Dismiss-screen terminal-state contract: a persistence failure (the ledger +// could not record the outcome, so a same-key retry guarantee does not hold) +// is non-retryable — the form must show its state-specific copy and disable +// the dismissal CTA. Retryable failures (in-progress, ambiguous, transport) +// keep the CTA so the user can retry under the same hoisted key. + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DismissFindingScreen } from './dismiss-finding-screen'; + +const PERSISTENCE_FAILED_MESSAGE = vi.hoisted( + () => 'We could not record this action. Please try again later.' +); +const IN_PROGRESS_COPY = vi.hoisted( + () => 'A security sync is already in progress. Please try again.' +); + +const routerBack = vi.hoisted(() => vi.fn()); +const dismiss = vi.hoisted(() => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null as Error | null, +})); +const capability = vi.hoisted(() => ({ + canManage: true, + isLoading: false, + isError: false, + refetch: vi.fn(), +})); +const finding = vi.hoisted(() => ({ + isLoading: false, + isError: false, + error: null as unknown, + data: { status: 'open' }, + refetch: vi.fn(), +})); +const pillGroup = vi.hoisted(() => ({ + onChange: (() => undefined) as (value: string) => void, +})); + +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + ActivityIndicator: 'ActivityIndicator', +})); +vi.mock('lucide-react-native', () => ({ ShieldOff: 'ShieldOff' })); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: routerBack }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#000', primaryForeground: '#fff' }), +})); +vi.mock('@/lib/hooks/use-security-agent', () => ({ + useSecurityAgentCapability: () => capability, +})); +vi.mock('@/lib/hooks/use-security-findings', () => ({ + useSecurityFinding: () => finding, + useDismissSecurityFinding: () => dismiss, +})); +// Faithful-enough mirror of the real classifier (covered by its own suite): +// the persistence-failure and replay-failed markers are non-retryable, the +// rest (transport, in-progress copy, ambiguous, settle-failed) are retryable. +vi.mock('@/lib/hooks/use-security-agent-mutations', () => ({ + isSecuritySyncRetryable: (error: unknown) => { + const message = error instanceof Error ? error.message : ''; + return !( + message === 'We could not record this action. Please try again later.' || + message === 'This action did not complete. Please try again.' || + message === 'operation_key_reuse_mismatch' + ); + }, +})); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); +vi.mock('@/components/empty-state', () => ({ EmptyState: () => null })); +vi.mock('@/components/query-error', () => ({ QueryError: () => null })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); +vi.mock('@/components/security-agent/settings-pill-group', () => ({ + PillGroup: (props: { onChange: (value: string) => void }) => { + pillGroup.onChange = props.onChange; + return null; + }, +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +type R = TestRenderer.ReactTestRenderer; +type I = TestRenderer.ReactTestInstance; + +function renderScreen(): R { + const ref: { current: R | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create( + createElement(DismissFindingScreen, { scope: 'personal', findingId: 'finding-1' }) + ); + }); + const r = ref.current; + if (!r) { + throw new Error('renderer was not created'); + } + return r; +} + +function selectReason(): void { + act(() => { + pillGroup.onChange('not_used'); + }); +} + +function findDismissButton(root: I): I { + const nodes = root.findAll(n => typeof n.type === 'string' && (n.type as string) === 'Button'); + if (nodes.length !== 1) { + throw new Error(`expected 1 dismissal Button, got ${nodes.length}`); + } + const n = nodes[0]; + if (!n) { + throw new Error('dismissal Button not found'); + } + return n; +} + +function buttonDisabled(root: I): boolean | undefined { + return findDismissButton(root).props.disabled as boolean | undefined; +} + +function renderedTexts(root: I): string[] { + return root + .findAll( + n => + typeof n.type === 'string' && + (n.type as string) === 'Text' && + typeof n.props.children === 'string' + ) + .map(n => n.props.children as string); +} + +describe('DismissFindingScreen dismissal CTA states', () => { + beforeEach(() => { + dismiss.mutate.mockClear(); + dismiss.isPending = false; + dismiss.isError = false; + dismiss.error = null; + capability.canManage = true; + capability.isLoading = false; + capability.isError = false; + finding.isLoading = false; + finding.isError = false; + finding.data = { status: 'open' }; + }); + + it('keeps the dismissal CTA enabled once a reason is chosen', () => { + const root = renderScreen(); + selectReason(); + + expect(buttonDisabled(root.root)).toBe(false); + }); + + it('keeps the dismissal CTA enabled after a retryable failure and shows its copy', () => { + dismiss.isError = true; + dismiss.error = new Error(IN_PROGRESS_COPY); + const root = renderScreen(); + selectReason(); + + expect(buttonDisabled(root.root)).toBe(false); + expect(renderedTexts(root.root)).toContain(IN_PROGRESS_COPY); + }); + + it('disables the dismissal CTA and shows the persistence-failure copy after a non-retryable error', () => { + dismiss.isError = true; + dismiss.error = new Error(PERSISTENCE_FAILED_MESSAGE); + const root = renderScreen(); + selectReason(); + + expect(buttonDisabled(root.root)).toBe(true); + expect(renderedTexts(root.root)).toContain(PERSISTENCE_FAILED_MESSAGE); + }); + + it('disables the dismissal CTA while the dismissal is pending', () => { + dismiss.isPending = true; + const root = renderScreen(); + selectReason(); + + expect(buttonDisabled(root.root)).toBe(true); + }); + + it('submits the dismissal and pops the screen only on success', () => { + const root = renderScreen(); + selectReason(); + + act(() => { + (findDismissButton(root.root).props.onPress as () => void)(); + }); + + expect(dismiss.mutate).toHaveBeenCalledWith( + expect.objectContaining({ findingId: 'finding-1', reason: 'not_used' }), + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); +}); diff --git a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx index 59d47fcdd9..bdf6f2651a 100644 --- a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx +++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useSecurityAgentCapability } from '@/lib/hooks/use-security-agent'; +import { isSecuritySyncRetryable } from '@/lib/hooks/use-security-agent-mutations'; import { useDismissSecurityFinding, useSecurityFinding } from '@/lib/hooks/use-security-findings'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -152,6 +153,12 @@ export function DismissFindingScreen({ scope, findingId }: Readonly @@ -190,7 +197,7 @@ export function DismissFindingScreen({ scope, findingId }: Readonly{dismissFinding.error.message} )} -