From 9b959078e2be964610572ecf007e12b2deefdcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:00:57 +0200 Subject: [PATCH 001/289] feat(notifications): add @kilocode/notifications package skeleton --- packages/notifications/package.json | 22 ++++++++++++++++++++++ packages/notifications/src/index.ts | 1 + packages/notifications/tsconfig.json | 19 +++++++++++++++++++ pnpm-lock.yaml | 13 +++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 packages/notifications/package.json create mode 100644 packages/notifications/src/index.ts create mode 100644 packages/notifications/tsconfig.json diff --git a/packages/notifications/package.json b/packages/notifications/package.json new file mode 100644 index 0000000000..4fe939ee6b --- /dev/null +++ b/packages/notifications/package.json @@ -0,0 +1,22 @@ +{ + "name": "@kilocode/notifications", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/notifications/src" + }, + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/notifications/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/notifications/tsconfig.json b/packages/notifications/tsconfig.json new file mode 100644 index 0000000000..01e8a72e0a --- /dev/null +++ b/packages/notifications/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cab9ab7eaa..e611890cfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1012,6 +1012,19 @@ importers: specifier: ^4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/notifications: + dependencies: + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260319.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/trpc: dependencies: '@kilocode/kiloclaw-secret-catalog': From cf90dc866e771ace1bcdea2b0747c5621f97384a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:01:23 +0200 Subject: [PATCH 002/289] feat(notifications): add presence-context path builders --- packages/notifications/src/index.ts | 2 +- packages/notifications/src/presence.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/notifications/src/presence.ts diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index cb0ff5c3b5..c700eb7972 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1 +1 @@ -export {}; +export * from './presence'; diff --git a/packages/notifications/src/presence.ts b/packages/notifications/src/presence.ts new file mode 100644 index 0000000000..8459b886c3 --- /dev/null +++ b/packages/notifications/src/presence.ts @@ -0,0 +1,16 @@ +/** + * Presence-context path builders. These contexts live under /presence/* + * and are subscribed by clients only when the user is *actively* on the + * matching surface. The notifications pipeline queries them via + * event-service.isUserInContext to skip pushes when the user is in-context. + */ + +export type Platform = 'app' | 'web'; + +export const presenceContextForPlatform = (platform: Platform) => `/presence/${platform}` as const; + +export const presenceContextForInstance = (sandboxId: string) => + `/presence/kiloclaw/${sandboxId}` as const; + +export const presenceContextForConversation = (sandboxId: string, conversationId: string) => + `/presence/kiloclaw/${sandboxId}/${conversationId}` as const; From e29e9cc667611d1e4e45566247a86948a1650a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:01:48 +0200 Subject: [PATCH 003/289] feat(notifications): add push-data schema --- packages/notifications/src/index.ts | 1 + packages/notifications/src/push-data.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 packages/notifications/src/push-data.ts diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index c700eb7972..2d9ea4df87 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1 +1,2 @@ export * from './presence'; +export * from './push-data'; diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts new file mode 100644 index 0000000000..975ee62315 --- /dev/null +++ b/packages/notifications/src/push-data.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +/** + * Schema for the `data` blob attached to Expo push notifications. + * This crosses the OS boundary as untyped JSON, so it MUST be + * Zod-parsed by the mobile notification handler before use. + */ +export const pushDataSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('chat.message'), + sandboxId: z.string(), + conversationId: z.string(), + messageId: z.string(), + }), +]); + +export type PushData = z.infer; From 73e32338df228187a764a1577b2d94680652ced1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:02:20 +0200 Subject: [PATCH 004/289] feat(notifications): add Zod schemas for push pipeline RPCs --- packages/notifications/src/index.ts | 1 + packages/notifications/src/rpc-schemas.ts | 67 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 packages/notifications/src/rpc-schemas.ts diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 2d9ea4df87..36bd476cad 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1,2 +1,3 @@ export * from './presence'; export * from './push-data'; +export * from './rpc-schemas'; diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts new file mode 100644 index 0000000000..9c832eb480 --- /dev/null +++ b/packages/notifications/src/rpc-schemas.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { pushDataSchema } from './push-data'; + +// ── sendPushForConversation ───────────────────────────────────────── + +export const sendPushForConversationInputSchema = z.object({ + conversationId: z.string().min(1), + sandboxId: z.string().min(1), + senderUserId: z.string().nullable(), + recipientUserIds: z.array(z.string().min(1)).min(1), + title: z.string().max(200), + bodyPreview: z.string().max(200), + messageId: z.string().min(1), +}); +export type SendPushForConversationInput = z.infer; + +export const perRecipientOutcomeSchema = z.enum([ + 'delivered', + 'suppressed_presence', + 'no_tokens', + 'duplicate', + 'failed', +]); +export type PerRecipientOutcome = z.infer; + +export const perRecipientResultSchema = z.object({ + userId: z.string(), + outcome: perRecipientOutcomeSchema, +}); +export type PerRecipientResult = z.infer; + +export const sendPushForConversationOutputSchema = z.object({ + perRecipient: z.array(perRecipientResultSchema), +}); +export type SendPushForConversationOutput = z.infer; + +// ── dispatchPush (internal DO RPC) ────────────────────────────────── + +export const dispatchPushInputSchema = z.object({ + userId: z.string().min(1), + presenceContext: z.string().min(1), + idempotencyKey: z.string().min(1), + badge: z + .object({ + badgeBucket: z.string().min(1), + delta: z.number().int(), + }) + .nullable(), + push: z.object({ + title: z.string(), + body: z.string(), + data: pushDataSchema, + sound: z.union([z.literal('default'), z.null()]).optional(), + priority: z.enum(['default', 'high']).optional(), + }), +}); +export type DispatchPushInput = z.infer; + +export const dispatchPushOutcomeSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('delivered'), tokenCount: z.number().int().nonnegative() }), + z.object({ kind: z.literal('suppressed_presence') }), + z.object({ kind: z.literal('no_tokens') }), + z.object({ kind: z.literal('duplicate') }), + z.object({ kind: z.literal('failed'), error: z.string() }), +]); +export type DispatchPushOutcome = z.infer; From ef9f434e8bc7012af85e7021c64f49f1d850e8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:03:05 +0200 Subject: [PATCH 005/289] feat(event-service): add UserSessionDO.hasContext --- .../src/__tests__/has-context.test.ts | 57 +++++++++++++++++++ .../event-service/src/do/user-session-do.ts | 11 ++++ 2 files changed, 68 insertions(+) create mode 100644 services/event-service/src/__tests__/has-context.test.ts diff --git a/services/event-service/src/__tests__/has-context.test.ts b/services/event-service/src/__tests__/has-context.test.ts new file mode 100644 index 0000000000..4cebaceff3 --- /dev/null +++ b/services/event-service/src/__tests__/has-context.test.ts @@ -0,0 +1,57 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +import { UserSessionDO } from '../do/user-session-do'; + +function getStub(userId: string) { + const id = env.USER_SESSION_DO.idFromName(userId); + return env.USER_SESSION_DO.get(id); +} + +async function attachContexts( + stub: ReturnType, + contexts: string[], +): Promise { + const res = await stub.fetch('https://do/connect', { + headers: { Upgrade: 'websocket' }, + }); + const ws = res.webSocket!; + ws.accept(); + ws.send(JSON.stringify({ type: 'context.subscribe', contexts })); + await new Promise(r => setTimeout(r, 50)); + return ws; +} + +describe('UserSessionDO.hasContext', () => { + it('returns false when no sockets are open', async () => { + const stub = getStub('user-no-sockets'); + await runInDurableObject(stub, async (instance: UserSessionDO) => { + expect(await instance.hasContext('/presence/web')).toBe(false); + }); + }); + + it('returns true when at least one socket has subscribed to the context', async () => { + const stub = getStub('user-one-sub'); + await attachContexts(stub, ['/presence/web']); + await runInDurableObject(stub, async (instance: UserSessionDO) => { + expect(await instance.hasContext('/presence/web')).toBe(true); + }); + }); + + it('returns false for contexts no socket has subscribed to', async () => { + const stub = getStub('user-other-sub'); + await attachContexts(stub, ['/presence/web']); + await runInDurableObject(stub, async (instance: UserSessionDO) => { + expect(await instance.hasContext('/presence/app')).toBe(false); + }); + }); + + it('returns true if any socket among many has the context', async () => { + const stub = getStub('user-many-subs'); + await attachContexts(stub, ['/presence/web']); + await attachContexts(stub, ['/presence/app']); + await runInDurableObject(stub, async (instance: UserSessionDO) => { + expect(await instance.hasContext('/presence/app')).toBe(true); + }); + }); +}); diff --git a/services/event-service/src/do/user-session-do.ts b/services/event-service/src/do/user-session-do.ts index 9d37c312d7..95adcdbb20 100644 --- a/services/event-service/src/do/user-session-do.ts +++ b/services/event-service/src/do/user-session-do.ts @@ -113,6 +113,17 @@ export class UserSessionDO extends DurableObject { }); } + async hasContext(context: string): Promise { + return withLogTags({ source: 'UserSessionDO.hasContext' }, () => { + logger.setTags({ userId: this.ctx.id.name, context }); + for (const ws of this.ctx.getWebSockets()) { + const state = this.getState(ws); + if (state.contexts.has(context)) return true; + } + return false; + }); + } + // ── Helpers ──────────────────────────────────────────────────────── private getState(ws: WebSocket): { contexts: Set } { From 7077754e6d16e21720c5497c1a5db0a97396683c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:04:28 +0200 Subject: [PATCH 006/289] feat(event-service): add isUserInContext RPC --- .../src/__tests__/has-context.test.ts | 4 +- .../src/__tests__/is-user-in-context.test.ts | 46 +++++++++++++++++++ services/event-service/src/index.ts | 6 +++ services/event-service/vitest.config.mts | 9 ++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 services/event-service/src/__tests__/is-user-in-context.test.ts diff --git a/services/event-service/src/__tests__/has-context.test.ts b/services/event-service/src/__tests__/has-context.test.ts index 4cebaceff3..db86a18b23 100644 --- a/services/event-service/src/__tests__/has-context.test.ts +++ b/services/event-service/src/__tests__/has-context.test.ts @@ -1,7 +1,7 @@ import { env, runInDurableObject } from 'cloudflare:test'; import { describe, expect, it } from 'vitest'; -import { UserSessionDO } from '../do/user-session-do'; +import type { UserSessionDO } from '../do/user-session-do'; function getStub(userId: string) { const id = env.USER_SESSION_DO.idFromName(userId); @@ -10,7 +10,7 @@ function getStub(userId: string) { async function attachContexts( stub: ReturnType, - contexts: string[], + contexts: string[] ): Promise { const res = await stub.fetch('https://do/connect', { headers: { Upgrade: 'websocket' }, diff --git a/services/event-service/src/__tests__/is-user-in-context.test.ts b/services/event-service/src/__tests__/is-user-in-context.test.ts new file mode 100644 index 0000000000..ec15f1e9d1 --- /dev/null +++ b/services/event-service/src/__tests__/is-user-in-context.test.ts @@ -0,0 +1,46 @@ +import { env } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +// EVENT_SERVICE_SELF is configured in vitest.config.mts via miniflare's +// kCurrentWorker symbol — it's a service binding pointed at this same worker +// so tests can invoke RPC methods on the WorkerEntrypoint directly. +function getSelf() { + return (env as unknown as Record).EVENT_SERVICE_SELF as { + isUserInContext(userId: string, context: string): Promise; + }; +} + +async function subscribe(userId: string, contexts: string[]): Promise { + const id = env.USER_SESSION_DO.idFromName(userId); + const stub = env.USER_SESSION_DO.get(id); + const res = await stub.fetch('https://do/connect', { + headers: { Upgrade: 'websocket' }, + }); + const ws = res.webSocket!; + ws.accept(); + ws.send(JSON.stringify({ type: 'context.subscribe', contexts })); + await new Promise(r => setTimeout(r, 50)); + return ws; +} + +describe('event-service isUserInContext', () => { + it('returns false when the user has no live sockets', async () => { + const self = getSelf(); + const result = await self.isUserInContext('user-no-sockets', '/presence/web'); + expect(result).toBe(false); + }); + + it('returns true when the user has a socket subscribed to the context', async () => { + await subscribe('user-with-sub', ['/presence/app']); + const self = getSelf(); + const result = await self.isUserInContext('user-with-sub', '/presence/app'); + expect(result).toBe(true); + }); + + it('returns false when the user is subscribed to a different context', async () => { + await subscribe('user-other-sub', ['/presence/app']); + const self = getSelf(); + const result = await self.isUserInContext('user-other-sub', '/presence/web'); + expect(result).toBe(false); + }); +}); diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 0fa6d99024..d02ea5c634 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -109,4 +109,10 @@ export default class extends WorkerEntrypoint { const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); return stub.pushEvent(context, event, payload); } + + async isUserInContext(userId: string, context: string): Promise { + logger.setTags({ userId, context }); + const stub = this.env.USER_SESSION_DO.get(this.env.USER_SESSION_DO.idFromName(userId)); + return stub.hasContext(context); + } } diff --git a/services/event-service/vitest.config.mts b/services/event-service/vitest.config.mts index c6a713c3e5..7eeac25a4a 100644 --- a/services/event-service/vitest.config.mts +++ b/services/event-service/vitest.config.mts @@ -1,11 +1,20 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; +// Self-referencing symbol: miniflare resolves this to the current (runner) worker, +// letting tests call RPC methods on our own entrypoint. +const kCurrentWorker = Symbol.for('miniflare.kCurrentWorker'); + export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: './wrangler.jsonc' }, isolatedStorage: false, + miniflare: { + serviceBindings: { + EVENT_SERVICE_SELF: kCurrentWorker as unknown as string, + }, + }, }, }, }, From 127e219a802ca2cb21e217fc9742abb23ce31d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:12:40 +0200 Subject: [PATCH 007/289] fix(notifications): require non-empty senderUserId Mirrors the .min(1) constraint used on every other userId/recipientUserIds field. Empty strings were silently passing where null is the intended sentinel for system-sent messages. --- packages/notifications/src/rpc-schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index 9c832eb480..1e606cde2b 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -7,7 +7,7 @@ import { pushDataSchema } from './push-data'; export const sendPushForConversationInputSchema = z.object({ conversationId: z.string().min(1), sandboxId: z.string().min(1), - senderUserId: z.string().nullable(), + senderUserId: z.union([z.string().min(1), z.null()]), recipientUserIds: z.array(z.string().min(1)).min(1), title: z.string().max(200), bodyPreview: z.string().max(200), From 73dcee56fc92fdb3208c11336c9431ffcd834c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 17:14:09 +0200 Subject: [PATCH 008/289] refactor(event-service): move presence-context builders to @kilocode/event-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /presence/* contexts are queried via event-service.isUserInContext and subscribed via the event-service WebSocket — they are an event-service concern, not a notifications-package concern. Notifications only consumes the resulting context strings. Non-presence event contexts (kilo-chat conversation events, etc.) will move into the same package in a follow-up; this PR ships only the presence builders since that is what later phases need. --- packages/event-service/src/index.ts | 1 + packages/{notifications => event-service}/src/presence.ts | 0 packages/notifications/src/index.ts | 1 - 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/{notifications => event-service}/src/presence.ts (100%) diff --git a/packages/event-service/src/index.ts b/packages/event-service/src/index.ts index 9beddc67a6..3a40272cbe 100644 --- a/packages/event-service/src/index.ts +++ b/packages/event-service/src/index.ts @@ -1,3 +1,4 @@ export { EventServiceClient, WebSocketAuthError, HandshakeTimeoutError } from './client'; +export * from './presence'; export * from './schemas'; export type * from './types'; diff --git a/packages/notifications/src/presence.ts b/packages/event-service/src/presence.ts similarity index 100% rename from packages/notifications/src/presence.ts rename to packages/event-service/src/presence.ts diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 36bd476cad..d9b3cb2df0 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1,3 +1,2 @@ -export * from './presence'; export * from './push-data'; export * from './rpc-schemas'; From 4b46ce7f934f3ba98969687979a2a020e7bdfec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 14:53:17 +0200 Subject: [PATCH 009/289] =?UTF-8?q?refactor(db):=20PR=202=20=E2=80=94=20re?= =?UTF-8?q?name=20channel=5Fbadge=5Fcounts=20to=20badge=5Fcounts=20(#2914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. --- apps/web/src/routers/user-router.test.ts | 14 +- apps/web/src/routers/user-router.ts | 26 +- .../src/migrations/0107_dapper_power_pack.sql | 9 + .../db/src/migrations/meta/0107_snapshot.json | 18016 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 27 +- packages/notifications/src/badge-buckets.ts | 11 + packages/notifications/src/index.ts | 1 + pnpm-lock.yaml | 3 + services/notifications/package.json | 1 + .../src/dos/NotificationChannelDO.ts | 22 +- 11 files changed, 18092 insertions(+), 45 deletions(-) create mode 100644 packages/db/src/migrations/0107_dapper_power_pack.sql create mode 100644 packages/db/src/migrations/meta/0107_snapshot.json create mode 100644 packages/notifications/src/badge-buckets.ts diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 954299ad87..8ecf8baade 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -1,6 +1,6 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { channel_badge_counts, kilocode_users } from '@kilocode/db/schema'; +import { badge_counts, kilocode_users } from '@kilocode/db/schema'; import { eq, inArray } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import type { User } from '@kilocode/db/schema'; @@ -425,18 +425,16 @@ describe('user router - getUnreadCounts', () => { const other = await insertTestUser({ google_user_email: `unread-counts-other-${crypto.randomUUID()}@example.com`, }); - await db.insert(channel_badge_counts).values([ - { user_id: user.id, channel_id: 'sandbox-mine', badge_count: 4 }, - { user_id: other.id, channel_id: 'sandbox-theirs', badge_count: 9 }, + await db.insert(badge_counts).values([ + { user_id: user.id, badge_bucket: 'sandbox-mine', badge_count: 4 }, + { user_id: other.id, badge_bucket: 'sandbox-theirs', badge_count: 9 }, ]); const caller = await createCallerForUser(user.id); const result = await caller.user.getUnreadCounts(); - expect(result).toEqual([{ channelId: 'sandbox-mine', badgeCount: 4 }]); + expect(result).toEqual([{ badgeBucket: 'sandbox-mine', badgeCount: 4 }]); - await db - .delete(channel_badge_counts) - .where(inArray(channel_badge_counts.user_id, [user.id, other.id])); + await db.delete(badge_counts).where(inArray(badge_counts.user_id, [user.id, other.id])); }); }); diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index 19d3a6964a..4d41e9623a 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -20,7 +20,7 @@ import { kiloclaw_instances, kiloclaw_subscriptions, user_push_tokens, - channel_badge_counts, + badge_counts, } from '@kilocode/db/schema'; import { eq, and, isNull, inArray, sql, gt, gte, sum } from 'drizzle-orm'; import crypto from 'crypto'; @@ -729,22 +729,22 @@ export const userRouter = createTRPCRouter({ // count for that channel to 0 and returns the new total across all // channels, which the app applies as the OS badge count. markChatRead: baseProcedure - .input(z.object({ channelId: z.string().min(1) })) + .input(z.object({ badgeBucket: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await db - .update(channel_badge_counts) + .update(badge_counts) .set({ badge_count: 0 }) .where( and( - eq(channel_badge_counts.user_id, ctx.user.id), - eq(channel_badge_counts.channel_id, input.channelId) + eq(badge_counts.user_id, ctx.user.id), + eq(badge_counts.badge_bucket, input.badgeBucket) ) ); const [totals] = await db - .select({ total: sum(channel_badge_counts.badge_count) }) - .from(channel_badge_counts) - .where(eq(channel_badge_counts.user_id, ctx.user.id)); + .select({ total: sum(badge_counts.badge_count) }) + .from(badge_counts) + .where(eq(badge_counts.user_id, ctx.user.id)); return { badgeCount: Number(totals?.total ?? 0) }; }), @@ -757,12 +757,10 @@ export const userRouter = createTRPCRouter({ getUnreadCounts: baseProcedure.query(async ({ ctx }) => { return readDb .select({ - channelId: channel_badge_counts.channel_id, - badgeCount: channel_badge_counts.badge_count, + badgeBucket: badge_counts.badge_bucket, + badgeCount: badge_counts.badge_count, }) - .from(channel_badge_counts) - .where( - and(eq(channel_badge_counts.user_id, ctx.user.id), gt(channel_badge_counts.badge_count, 0)) - ); + .from(badge_counts) + .where(and(eq(badge_counts.user_id, ctx.user.id), gt(badge_counts.badge_count, 0))); }), }); diff --git a/packages/db/src/migrations/0107_dapper_power_pack.sql b/packages/db/src/migrations/0107_dapper_power_pack.sql new file mode 100644 index 0000000000..0d5d3f3185 --- /dev/null +++ b/packages/db/src/migrations/0107_dapper_power_pack.sql @@ -0,0 +1,9 @@ +ALTER TABLE "channel_badge_counts" RENAME TO "badge_counts";--> statement-breakpoint +ALTER TABLE "badge_counts" RENAME COLUMN "channel_id" TO "badge_bucket";--> statement-breakpoint +ALTER TABLE "badge_counts" DROP CONSTRAINT "channel_badge_counts_user_id_kilocode_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "badge_counts" DROP CONSTRAINT "channel_badge_counts_user_id_channel_id_pk";--> statement-breakpoint +ALTER TABLE "badge_counts" ADD CONSTRAINT "badge_counts_user_id_badge_bucket_pk" PRIMARY KEY("user_id","badge_bucket");--> statement-breakpoint +ALTER TABLE "badge_counts" ADD CONSTRAINT "badge_counts_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +DELETE FROM badge_counts; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0107_snapshot.json b/packages/db/src/migrations/meta/0107_snapshot.json new file mode 100644 index 0000000000..c9b3c910b4 --- /dev/null +++ b/packages/db/src/migrations/meta/0107_snapshot.json @@ -0,0 +1,18016 @@ +{ + "id": "5b345e9c-de70-4377-a842-4747d863b166", + "prevId": "ec49ba08-673e-479f-a8a2-490c71ad9186", + "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_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_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_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 + }, + "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": {} + } + }, + "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.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_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 + }, + "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": "'v1'" + } + }, + "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": {} + } + }, + "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.badge_counts": { + "name": "badge_counts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "badge_bucket": { + "name": "badge_bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "badge_count": { + "name": "badge_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "badge_counts_user_id_kilocode_users_id_fk": { + "name": "badge_counts_user_id_kilocode_users_id_fk", + "tableFrom": "badge_counts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "badge_counts_user_id_badge_bucket_pk": { + "name": "badge_counts_user_id_badge_bucket_pk", + "columns": [ + "user_id", + "badge_bucket" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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": false + }, + "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 + }, + "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_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 + }, + "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 + }, + "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": {} + } + }, + "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_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 + }, + "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'" + }, + "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 + }, + "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_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_repo_pr_sha", + "columns": [ + { + "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, + "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 + }, + "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_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.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_file_hash": { + "name": "IDX_code_indexing_manifest_file_hash", + "columns": [ + { + "expression": "file_hash", + "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.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 + }, + "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": {}, + "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.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.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 + }, + "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": {} + } + }, + "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.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 + }, + "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": {} + }, + "idx_free_model_usage_user_created_at": { + "name": "idx_free_model_usage_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, + "where": "\"free_model_usage\".\"kilo_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.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', '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', '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')" + } + }, + "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 + }, + "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', 'cron')" + } + }, + "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_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 + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_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": {} + } + }, + "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_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.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 + }, + "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": { + "name": "UQ_kiloclaw_email_log_user_instance_type", + "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" + } + ], + "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 + } + }, + "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": {} + } + }, + "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": {}, + "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 + }, + "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_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_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_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 + }, + "is_admin": { + "name": "is_admin", + "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 + }, + "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 + } + }, + "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_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": { + "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" + } + }, + "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()" + } + }, + "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": {} + } + }, + "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.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_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 + } + }, + "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": {} + } + }, + "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.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.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_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 + }, + "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": {}, + "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_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 + }, + "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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + } + }, + "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_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 + }, + "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": {} + }, + "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.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_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 + }, + "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 + }, + "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": {} + } + }, + "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.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" + } + }, + "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": { + "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": { + "uq_security_findings_source": { + "name": "uq_security_findings_source", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "source", + "source_id" + ] + } + }, + "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.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.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_fingerprint_data": { + "name": "idx_fingerprint_data", + "columns": [ + { + "expression": "fingerprint_data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_reasons": { + "name": "idx_reasons", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "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.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_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 + } + }, + "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 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 8ed1009b42..145070107c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -750,6 +750,13 @@ "when": 1777411448037, "tag": "0106_petite_william_stryker", "breakpoints": true + }, + { + "idx": 107, + "version": "7", + "when": 1777476540389, + "tag": "0107_dapper_power_pack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 610f216ffa..11abc622ea 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -4598,29 +4598,30 @@ export const security_advisor_content = pgTable('security_advisor_content', { export type SecurityAdvisorContent = typeof security_advisor_content.$inferSelect; export type NewSecurityAdvisorContent = typeof security_advisor_content.$inferInsert; -// ============ CHANNEL BADGE COUNTS ============ -// Per-user per-channel unread notification counts for mobile app badge display. -// (user_id, channel_id) is the composite PK — one row per user per chat channel. -// Keyed by channel rather than instance to support multiple channels per instance -// in future. The notification service increments badge_count on each push and sums -// across all channels to get the total badge count to include in the push payload. -// The mobile client resets a channel's count (to 0) when the user views that chat. - -export const channel_badge_counts = pgTable( - 'channel_badge_counts', +// ============ BADGE COUNTS ============ +// Per-user per-bucket unread notification counts for mobile app badge display. +// (user_id, badge_bucket) is the composite PK — one row per user per bucket. +// badge_bucket is a free-form string chosen by the producer (e.g. sandbox_id +// today, conversation id later). The notification service increments badge_count +// on each push and sums across all buckets to get the total badge count to +// include in the push payload. The client resets a bucket's count (to 0) when +// the user views that item. + +export const badge_counts = pgTable( + 'badge_counts', { user_id: text() .notNull() .references(() => kilocode_users.id, { onDelete: 'cascade' }), - channel_id: text().notNull(), + badge_bucket: text().notNull(), badge_count: integer().notNull().default(0), updated_at: timestamp({ withTimezone: true, mode: 'string' }) .defaultNow() .notNull() .$onUpdateFn(() => sql`now()`), }, - table => [primaryKey({ columns: [table.user_id, table.channel_id] })] + table => [primaryKey({ columns: [table.user_id, table.badge_bucket] })] ); -export type ChannelBadgeCount = typeof channel_badge_counts.$inferSelect; +export type BadgeCount = typeof badge_counts.$inferSelect; export type NewSecurityAdvisorScan = typeof security_advisor_scans.$inferInsert; diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts new file mode 100644 index 0000000000..bb3213307c --- /dev/null +++ b/packages/notifications/src/badge-buckets.ts @@ -0,0 +1,11 @@ +/** + * Badge-bucket key builders. The `badge_counts` table uses a free-form + * `badge_bucket` string as part of its composite PK; producers of unread + * counts MUST derive their bucket key via these helpers so namespaces + * don't collide as more surfaces start emitting badge updates. + */ + +export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const; + +export const badgeBucketForConversation = (sandboxId: string, conversationId: string) => + `kiloclaw:${sandboxId}:${conversationId}` as const; diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index d9b3cb2df0..1b6581a13e 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1,2 +1,3 @@ +export * from './badge-buckets'; export * from './push-data'; export * from './rpc-schemas'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e611890cfc..00bae11778 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2008,6 +2008,9 @@ importers: '@kilocode/db': specifier: workspace:* version: link:../../packages/db + '@kilocode/notifications': + specifier: workspace:* + version: link:../../packages/notifications '@kilocode/worker-utils': specifier: workspace:* version: link:../../packages/worker-utils diff --git a/services/notifications/package.json b/services/notifications/package.json index 952f310a92..a7897326d8 100644 --- a/services/notifications/package.json +++ b/services/notifications/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@kilocode/db": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "catalog:", "expo-server-sdk": "^6.1.0", diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index a58bfda8bc..3568f9a8e7 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,6 +1,7 @@ import { DurableObject } from 'cloudflare:workers'; import { getWorkerDb } from '@kilocode/db/client'; -import { channel_badge_counts, kiloclaw_instances, user_push_tokens } from '@kilocode/db/schema'; +import { badge_counts, kiloclaw_instances, user_push_tokens } from '@kilocode/db/schema'; +import { badgeBucketForInstance } from '@kilocode/notifications'; import { and, eq, inArray, isNull, sql, sum } from 'drizzle-orm'; import type { Event } from 'stream-chat'; @@ -135,22 +136,23 @@ export class NotificationChannelDO extends DurableObject { return; } - // Increment the badge count for this channel and return the new total across all channels. + // Increment the badge count for this bucket and return the new total across all buckets. // Done before the token guard so unread state is always persisted even if the user // temporarily has no registered push tokens (e.g. between reinstalls). - // Uses UPSERT so the row is created on first notification for this channel. + // Uses UPSERT so the row is created on first notification for this bucket. + const badgeBucket = badgeBucketForInstance(sandboxId); await db - .insert(channel_badge_counts) - .values({ user_id: instance.user_id, channel_id: sandboxId, badge_count: 1 }) + .insert(badge_counts) + .values({ user_id: instance.user_id, badge_bucket: badgeBucket, badge_count: 1 }) .onConflictDoUpdate({ - target: [channel_badge_counts.user_id, channel_badge_counts.channel_id], - set: { badge_count: sql`${channel_badge_counts.badge_count} + 1` }, + target: [badge_counts.user_id, badge_counts.badge_bucket], + set: { badge_count: sql`${badge_counts.badge_count} + 1` }, }); const [totals] = await db - .select({ total: sum(channel_badge_counts.badge_count) }) - .from(channel_badge_counts) - .where(eq(channel_badge_counts.user_id, instance.user_id)); + .select({ total: sum(badge_counts.badge_count) }) + .from(badge_counts) + .where(eq(badge_counts.user_id, instance.user_id)); const badgeCount = Number(totals?.total ?? 0); From 8147303b585924dbdf30efa506bab7940b063c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:01:01 +0200 Subject: [PATCH 010/289] =?UTF-8?q?feat(notifications,kilo-chat):=20PR=203?= =?UTF-8?q?=20=E2=80=94=20notifications=20rewrite=20+=20push=20on=20messag?= =?UTF-8?q?e.created=20(#2918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. --- .../src/components/home/home-screen.tsx | 11 +- apps/mobile/src/components/kiloclaw/chat.tsx | 22 +- apps/mobile/src/lib/badge-buckets.ts | 1 + .../mobile/src/lib/hooks/use-unread-counts.ts | 11 +- apps/mobile/src/lib/notifications.ts | 27 +- packages/event-service/src/index.ts | 1 + .../event-service/src/kiloclaw-contexts.ts | 13 + packages/event-service/src/presence.ts | 9 +- pnpm-lock.yaml | 50 ++- services/kilo-chat/package.json | 2 + .../conversation-status-routes.test.ts | 3 +- .../src/__tests__/push-notifications.test.ts | 173 +++++++++++ services/kilo-chat/src/__tests__/setup.ts | 6 + services/kilo-chat/src/bindings.d.ts | 9 + services/kilo-chat/src/services/event-push.ts | 5 +- services/kilo-chat/src/services/messages.ts | 28 ++ .../kilo-chat/src/services/sandbox-lookup.ts | 18 ++ services/kilo-chat/src/util/content.ts | 12 + services/kilo-chat/vitest.config.mts | 16 + services/kilo-chat/worker-configuration.d.ts | 3 +- services/kilo-chat/wrangler.jsonc | 5 + services/notifications/package.json | 4 +- .../src/__tests__/dispatch-push.test.ts | 228 ++++++++++++++ services/notifications/src/__tests__/env.d.ts | 7 + .../send-push-for-conversation.test.ts | 70 +++++ services/notifications/src/__tests__/setup.ts | 17 + services/notifications/src/bindings.d.ts | 14 + .../src/dos/NotificationChannelDO.ts | 293 +++++++----------- services/notifications/src/index.ts | 85 ++++- services/notifications/src/routes/webhooks.ts | 45 --- services/notifications/tsconfig.json | 2 +- services/notifications/vitest.config.mts | 23 ++ .../notifications/worker-configuration.d.ts | 6 +- services/notifications/wrangler.jsonc | 10 +- 34 files changed, 956 insertions(+), 273 deletions(-) create mode 100644 apps/mobile/src/lib/badge-buckets.ts create mode 100644 packages/event-service/src/kiloclaw-contexts.ts create mode 100644 services/kilo-chat/src/__tests__/push-notifications.test.ts create mode 100644 services/kilo-chat/src/services/sandbox-lookup.ts create mode 100644 services/kilo-chat/src/util/content.ts create mode 100644 services/notifications/src/__tests__/dispatch-push.test.ts create mode 100644 services/notifications/src/__tests__/env.d.ts create mode 100644 services/notifications/src/__tests__/send-push-for-conversation.test.ts create mode 100644 services/notifications/src/__tests__/setup.ts create mode 100644 services/notifications/src/bindings.d.ts delete mode 100644 services/notifications/src/routes/webhooks.ts diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index 8975232ffe..cbf474425c 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -15,6 +15,7 @@ import { isTransitionalStatus } from '@/components/kiloclaw/status-badge'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; +import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { type ClawInstance, useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useUnreadCounts } from '@/lib/hooks/use-unread-counts'; @@ -79,7 +80,7 @@ export function HomeScreen() { isPending: instancesPending, isError: instancesError, } = useAllKiloClawInstances(pickListPollInterval); - const { byChannel: unreadByChannel } = useUnreadCounts(); + const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts(); const { storedSessions, activeSessions, @@ -128,7 +129,7 @@ export function HomeScreen() { {renderKiloClawSlot({ instances: instances ?? [], instancesError, - unreadByChannel, + unreadByBadgeBucket, })} {renderSessionsOrPromo({ @@ -151,7 +152,7 @@ export function HomeScreen() { function renderKiloClawSlot(params: { instances: ClawInstance[]; instancesError: boolean; - unreadByChannel: Map; + unreadByBadgeBucket: Map; }) { if (params.instances.length > 0) { return ( @@ -162,7 +163,9 @@ function renderKiloClawSlot(params: { ))} diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx index 626cde8230..e75b60862b 100644 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ b/apps/mobile/src/components/kiloclaw/chat.tsx @@ -15,10 +15,15 @@ import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell'; import { useBotOnlineStatus } from '@/components/kiloclaw/chat-hooks'; import { NotificationPrompt } from '@/components/kiloclaw/notification-prompt'; import { useStreamChatTheme } from '@/components/kiloclaw/chat-theme'; +import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; import { setLastActiveInstance } from '@/lib/last-active-instance'; -import { parseNotificationData, setActiveChatInstance } from '@/lib/notifications'; +import { + getNotificationSandboxId, + parseNotificationData, + setActiveChatInstance, +} from '@/lib/notifications'; import { useTRPC } from '@/lib/trpc'; type KiloClawChatProps = { @@ -28,7 +33,7 @@ type KiloClawChatProps = { organizationId?: string | null; }; -type UnreadCountsData = { channelId: string; badgeCount: number }[]; +type UnreadCountsData = { badgeBucket: string; badgeCount: number }[]; export function KiloClawChat({ instanceId, @@ -46,11 +51,11 @@ export function KiloClawChat({ const { mutate: markChatRead } = useMutation( trpc.user.markChatRead.mutationOptions({ - onMutate: async ({ channelId }) => { + onMutate: async ({ badgeBucket }) => { await queryClient.cancelQueries({ queryKey: unreadCountsKey }); const previous = queryClient.getQueryData(unreadCountsKey); queryClient.setQueryData(unreadCountsKey, old => - (old ?? []).filter(row => row.channelId !== channelId) + (old ?? []).filter(row => row.badgeBucket !== badgeBucket) ); return { previous }; }, @@ -71,18 +76,19 @@ export function KiloClawChat({ useFocusEffect( useCallback(() => { + const badgeBucket = badgeBucketForInstance(instanceId); isFocusedRef.current = true; setActiveChatInstance(instanceId); setLastActiveInstance(instanceId); - markChatRead({ channelId: instanceId }); + markChatRead({ badgeBucket }); // If a notification for this chat arrives while the screen is already open it is // visually suppressed, but the DO still incremented the server-side count. Clear // it immediately so the badge never drifts above 0 while the user is reading. const subscription = Notifications.addNotificationReceivedListener(notification => { const data = parseNotificationData(notification.request.content.data); - if (data?.type === 'chat' && data.instanceId === instanceId) { - markChatRead({ channelId: instanceId }); + if (data && getNotificationSandboxId(data) === instanceId) { + markChatRead({ badgeBucket }); } }); @@ -100,7 +106,7 @@ export function KiloClawChat({ // not an app-state one), so without this the badge stays stuck after backgrounding. useEffect(() => { if (isActive && isFocusedRef.current) { - markChatRead({ channelId: instanceId }); + markChatRead({ badgeBucket: badgeBucketForInstance(instanceId) }); } }, [isActive, instanceId, markChatRead]); diff --git a/apps/mobile/src/lib/badge-buckets.ts b/apps/mobile/src/lib/badge-buckets.ts new file mode 100644 index 0000000000..cb32814020 --- /dev/null +++ b/apps/mobile/src/lib/badge-buckets.ts @@ -0,0 +1 @@ +export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const; diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index e69e93fdc9..f88f95cabc 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -4,9 +4,8 @@ import { useMemo } from 'react'; import { useTRPC } from '@/lib/trpc'; /** - * Fetches per-channel unread message counts for the current user and returns - * a Map keyed by channelId for O(1) lookup from dashboard cards. For kiloclaw - * chats, `channelId` equals the instance's `sandboxId`. + * Fetches unread message counts for the current user and returns a Map keyed + * by badge bucket for O(1) lookup from dashboard cards. * * Freshness is driven by invalidations, not polling: * - Foreground chat push → invalidate (see `use-unread-counts-invalidation`). @@ -21,13 +20,13 @@ export function useUnreadCounts() { }) ); - const byChannel = useMemo(() => { + const byBadgeBucket = useMemo(() => { const map = new Map(); for (const row of query.data ?? []) { - map.set(row.channelId, row.badgeCount); + map.set(row.badgeBucket, row.badgeCount); } return map; }, [query.data]); - return { byChannel, query }; + return { byBadgeBucket, query }; } diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index c20858c069..7045ad9b22 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -24,11 +24,18 @@ export function setActiveChatInstance(instanceId: string | null) { activeChatInstanceId = instanceId; } -// Keep in sync with data field in services/notifications/src/dos/NotificationChannelDO.ts -const notificationDataSchema = z.object({ - type: z.literal('chat'), - instanceId: z.string().min(1), -}); +const notificationDataSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('chat'), + instanceId: z.string().min(1), + }), + z.object({ + type: z.literal('chat.message'), + sandboxId: z.string().min(1), + conversationId: z.string().min(1), + messageId: z.string().min(1), + }), +]); type NotificationData = z.infer; @@ -40,6 +47,10 @@ export function parseNotificationData(data: unknown): NotificationData | null { return parsed.success ? parsed.data : null; } +export function getNotificationSandboxId(data: NotificationData): string { + return data.type === 'chat' ? data.instanceId : data.sandboxId; +} + const shown = { shouldShowAlert: true, shouldPlaySound: true, @@ -63,7 +74,7 @@ export function setupNotificationHandler() { const data = parseNotificationData(notification.request.content.data); // Suppress only if the user is already viewing this exact chat - if (data && data.instanceId === activeChatInstanceId) { + if (data && getNotificationSandboxId(data) === activeChatInstanceId) { return suppressed; } @@ -87,7 +98,7 @@ export function setupNotificationResponseHandler() { const data = parseNotificationData(response.notification.request.content.data); if (data) { - const path = `/(app)/chat/${data.instanceId}`; + const path = `/(app)/chat/${getNotificationSandboxId(data)}`; // If the router is ready (has segments), navigate immediately. // Otherwise store as pending for consumption after auth completes. try { @@ -109,7 +120,7 @@ export function checkInitialNotification(): void { } const data = parseNotificationData(response.notification.request.content.data); if (data) { - pendingNotificationLink = `/(app)/chat/${data.instanceId}`; + pendingNotificationLink = `/(app)/chat/${getNotificationSandboxId(data)}`; } } diff --git a/packages/event-service/src/index.ts b/packages/event-service/src/index.ts index 3a40272cbe..c2e238f1dd 100644 --- a/packages/event-service/src/index.ts +++ b/packages/event-service/src/index.ts @@ -1,4 +1,5 @@ export { EventServiceClient, WebSocketAuthError, HandshakeTimeoutError } from './client'; export * from './presence'; +export * from './kiloclaw-contexts'; export * from './schemas'; export type * from './types'; diff --git a/packages/event-service/src/kiloclaw-contexts.ts b/packages/event-service/src/kiloclaw-contexts.ts new file mode 100644 index 0000000000..afbb6bc665 --- /dev/null +++ b/packages/event-service/src/kiloclaw-contexts.ts @@ -0,0 +1,13 @@ +/** + * Event-context path builders for kiloclaw event subscriptions. + * + * These are the contexts on which kilo-chat publishes events (message + * created, typing, etc.) and to which clients subscribe to receive + * those events. Distinct from `/presence/*` contexts, which signal + * whether the user is actively on a surface. + */ + +export const kiloclawInstanceContext = (sandboxId: string) => `/kiloclaw/${sandboxId}` as const; + +export const kiloclawConversationContext = (sandboxId: string, conversationId: string) => + `/kiloclaw/${sandboxId}/${conversationId}` as const; diff --git a/packages/event-service/src/presence.ts b/packages/event-service/src/presence.ts index 8459b886c3..a267aa667c 100644 --- a/packages/event-service/src/presence.ts +++ b/packages/event-service/src/presence.ts @@ -3,14 +3,19 @@ * and are subscribed by clients only when the user is *actively* on the * matching surface. The notifications pipeline queries them via * event-service.isUserInContext to skip pushes when the user is in-context. + * + * The kiloclaw-scoped variants compose `/presence` with the corresponding + * event-context paths so the segment shape is defined in exactly one place. */ +import { kiloclawConversationContext, kiloclawInstanceContext } from './kiloclaw-contexts'; + export type Platform = 'app' | 'web'; export const presenceContextForPlatform = (platform: Platform) => `/presence/${platform}` as const; export const presenceContextForInstance = (sandboxId: string) => - `/presence/kiloclaw/${sandboxId}` as const; + `/presence${kiloclawInstanceContext(sandboxId)}` as const; export const presenceContextForConversation = (sandboxId: string, conversationId: string) => - `/presence/kiloclaw/${sandboxId}/${conversationId}` as const; + `/presence${kiloclawConversationContext(sandboxId, conversationId)}` as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00bae11778..3ea3a33947 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1561,7 +1561,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1808,9 +1808,15 @@ importers: '@kilocode/encryption': specifier: workspace:* version: link:../../packages/encryption + '@kilocode/event-service': + specifier: workspace:* + version: link:../../packages/event-service '@kilocode/kilo-chat': specifier: workspace:* version: link:../../packages/kilo-chat + '@kilocode/notifications': + specifier: workspace:* + version: link:../../packages/notifications '@kilocode/worker-utils': specifier: workspace:* version: link:../../packages/worker-utils @@ -2008,6 +2014,9 @@ importers: '@kilocode/db': specifier: workspace:* version: link:../../packages/db + '@kilocode/event-service': + specifier: workspace:* + version: link:../../packages/event-service '@kilocode/notifications': specifier: workspace:* version: link:../../packages/notifications @@ -2023,9 +2032,6 @@ importers: hono: specifier: ^4.12.7 version: 4.12.8 - stream-chat: - specifier: 'catalog:' - version: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: specifier: 'catalog:' version: 4.3.6 @@ -16744,7 +16750,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.4 miniflare: 4.20260310.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: 4.72.0(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -22252,7 +22258,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -25683,6 +25689,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -26334,6 +26359,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} diff --git a/services/kilo-chat/package.json b/services/kilo-chat/package.json index 9eb1492829..283de5871b 100644 --- a/services/kilo-chat/package.json +++ b/services/kilo-chat/package.json @@ -27,7 +27,9 @@ "dependencies": { "@kilocode/db": "workspace:*", "@kilocode/encryption": "workspace:*", + "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "catalog:", "hono": "catalog:", diff --git a/services/kilo-chat/src/__tests__/conversation-status-routes.test.ts b/services/kilo-chat/src/__tests__/conversation-status-routes.test.ts index 4b6330c0ac..022f803484 100644 --- a/services/kilo-chat/src/__tests__/conversation-status-routes.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-status-routes.test.ts @@ -1,6 +1,7 @@ import { env } from 'cloudflare:test'; import { describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; +import { kiloclawConversationContext } from '@kilocode/event-service'; import type { AuthContext } from '../auth'; import { botAuthMiddleware } from '../auth-bot'; import { registerBotRoutes } from '../routes/bot-messages'; @@ -246,7 +247,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/conversations/:cid/conversation-stat expect(pushEvent).toHaveBeenCalledTimes(1); expect(pushEvent).toHaveBeenCalledWith( userId, - `/kiloclaw/${sandboxId}/${conversationId}`, + kiloclawConversationContext(sandboxId, conversationId), 'conversation.status', { conversationId, diff --git a/services/kilo-chat/src/__tests__/push-notifications.test.ts b/services/kilo-chat/src/__tests__/push-notifications.test.ts new file mode 100644 index 0000000000..920bae946c --- /dev/null +++ b/services/kilo-chat/src/__tests__/push-notifications.test.ts @@ -0,0 +1,173 @@ +import { env } from 'cloudflare:test'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ConversationDO } from '../do/conversation-do'; +import { makeApp } from './helpers'; + +// fetchSandboxLabel hits Hyperdrive/pg. Mock it so the push call site doesn't +// need a real DB. Individual tests can override per-test as needed. +vi.mock('../services/sandbox-lookup', () => ({ + fetchSandboxLabel: vi.fn(async () => 'My Sandbox'), +})); + +const sampleContent = [{ type: 'text', text: 'hello there' }]; + +async function waitForCalls(spy: { mock: { calls: unknown[][] } }, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (spy.mock.calls.length > 0) return; + await new Promise(r => setTimeout(r, 10)); + } +} + +describe('kilo-chat publishes push on message.created', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('does NOT call sendPushForConversation when only the sender is a human member', async () => { + // Single-human conversation: sender + bot. After excluding the sender, + // recipientUserIds is empty, so the push fanout must be skipped. + const sendSpy = vi + .spyOn(env.NOTIFICATIONS, 'sendPushForConversation') + .mockResolvedValue({ perRecipient: [] }); + + const userId = 'user-push-skip-1'; + const sandboxId = 'sandbox-push-skip-1'; + const userApp = makeApp(userId, 'user'); + + const createRes = await userApp.request( + '/v1/conversations', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sandboxId, title: 'Push skip' }), + }, + env + ); + expect(createRes.status).toBe(201); + const { conversationId } = await createRes.json<{ conversationId: string }>(); + + const sendRes = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + env + ); + expect(sendRes.status).toBe(201); + + // Give any waitUntil tasks a chance to fire then assert the push wasn't + // called — there are no human recipients other than the sender. + await new Promise(r => setTimeout(r, 50)); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('calls sendPushForConversation with non-sender humans when conversation has multiple humans', async () => { + const sendSpy = vi + .spyOn(env.NOTIFICATIONS, 'sendPushForConversation') + .mockResolvedValue({ perRecipient: [] }); + + const senderId = 'user-push-multi-sender'; + const otherId = 'user-push-multi-other'; + const sandboxId = 'sandbox-push-multi'; + const conversationId = '01KQD0T86VR3M1RPQCF4WBFX1W'; + const botId = `bot:kiloclaw:${sandboxId}`; + + // Seed a multi-human conversation directly via the ConversationDO so we + // can exercise the push fanout's non-sender recipient path. + const convStub: DurableObjectStub = env.CONVERSATION_DO.get( + env.CONVERSATION_DO.idFromName(conversationId) + ); + const initRes = await convStub.initialize({ + id: conversationId, + title: 'Multi-human', + createdBy: senderId, + createdAt: Date.now(), + members: [ + { id: senderId, kind: 'user' }, + { id: otherId, kind: 'user' }, + { id: botId, kind: 'bot' }, + ], + }); + expect(initRes.ok).toBe(true); + + const senderApp = makeApp(senderId, 'user'); + const sendRes = await senderApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + env + ); + expect(sendRes.status).toBe(201); + const { messageId } = await sendRes.json<{ messageId: string }>(); + + await waitForCalls(sendSpy); + expect(sendSpy).toHaveBeenCalledTimes(1); + const call = sendSpy.mock.calls[0][0] as { + conversationId: string; + sandboxId: string; + senderUserId: string | null; + recipientUserIds: string[]; + title: string; + bodyPreview: string; + messageId: string; + }; + expect(call.conversationId).toBe(conversationId); + expect(call.sandboxId).toBe(sandboxId); + expect(call.senderUserId).toBe(senderId); + expect(call.recipientUserIds).toContain(otherId); + expect(call.recipientUserIds).not.toContain(senderId); + expect(call.bodyPreview).toContain('hello there'); + expect(call.title).toContain('My Sandbox'); + expect(call.messageId).toBe(messageId); + }); + + it('does not block the send when sendPushForConversation rejects', async () => { + vi.spyOn(env.NOTIFICATIONS, 'sendPushForConversation').mockRejectedValue( + new Error('downstream blew up') + ); + + const senderId = 'user-push-throw-sender'; + const otherId = 'user-push-throw-other'; + const sandboxId = 'sandbox-push-throw'; + const conversationId = '01KQD0T86WRTBR2NXX0VX3MY1M'; + const botId = `bot:kiloclaw:${sandboxId}`; + + const convStub: DurableObjectStub = env.CONVERSATION_DO.get( + env.CONVERSATION_DO.idFromName(conversationId) + ); + const initRes = await convStub.initialize({ + id: conversationId, + title: 'Throw', + createdBy: senderId, + createdAt: Date.now(), + members: [ + { id: senderId, kind: 'user' }, + { id: otherId, kind: 'user' }, + { id: botId, kind: 'bot' }, + ], + }); + expect(initRes.ok).toBe(true); + + const senderApp = makeApp(senderId, 'user'); + // Even with the push throwing inside the post-commit fan-out, the send + // must still succeed because the failure is swallowed by try/catch. + const sendRes = await senderApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + env + ); + expect(sendRes.status).toBe(201); + const body = await sendRes.json<{ messageId: string }>(); + expect(body.messageId).toBeTruthy(); + }); +}); diff --git a/services/kilo-chat/src/__tests__/setup.ts b/services/kilo-chat/src/__tests__/setup.ts index 897c3abf77..5807193965 100644 --- a/services/kilo-chat/src/__tests__/setup.ts +++ b/services/kilo-chat/src/__tests__/setup.ts @@ -15,3 +15,9 @@ vi.mock('../services/user-lookup', () => ({ invalid: [], })), })); + +// sandbox-lookup imports @kilocode/db/client → pg which doesn't work in the +// Workers runtime. Mock globally so module resolution succeeds. +vi.mock('../services/sandbox-lookup', () => ({ + fetchSandboxLabel: vi.fn(async () => 'Sandbox'), +})); diff --git a/services/kilo-chat/src/bindings.d.ts b/services/kilo-chat/src/bindings.d.ts index 842333f2a1..d6580663b6 100644 --- a/services/kilo-chat/src/bindings.d.ts +++ b/services/kilo-chat/src/bindings.d.ts @@ -1,5 +1,9 @@ import type { z } from 'zod'; import type { chatWebhookRpcSchema, KiloChatEventName } from '@kilocode/kilo-chat'; +import type { + SendPushForConversationInput, + SendPushForConversationOutput, +} from '@kilocode/notifications'; // Augment the wrangler-generated Env with RPC method signatures for service // bindings. `worker-configuration.d.ts` types these as plain Fetcher; this @@ -23,6 +27,11 @@ declare global { payload: unknown ): Promise; }; + NOTIFICATIONS: Fetcher & { + sendPushForConversation( + input: SendPushForConversationInput + ): Promise; + }; } } diff --git a/services/kilo-chat/src/services/event-push.ts b/services/kilo-chat/src/services/event-push.ts index 423552ed2a..a1491c25f8 100644 --- a/services/kilo-chat/src/services/event-push.ts +++ b/services/kilo-chat/src/services/event-push.ts @@ -4,6 +4,7 @@ import type { BotStatusRequest, ConversationStatusRequest, } from '@kilocode/kilo-chat'; +import { kiloclawConversationContext, kiloclawInstanceContext } from '@kilocode/event-service'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; import { lookupSandboxOwnerUserId } from './sandbox-ownership'; @@ -26,7 +27,7 @@ export async function pushEventToHumanMembers( ): Promise> { const es = getEventService(env); if (!es) return new Map(); - const context = `/kiloclaw/${sandboxId}/${conversationId}`; + const context = kiloclawConversationContext(sandboxId, conversationId); const results = await Promise.allSettled( humanMemberIds.map(async userId => { @@ -65,7 +66,7 @@ export async function pushInstanceEvent( ): Promise { const es = getEventService(env); if (!es) return; - const context = `/kiloclaw/${sandboxId}`; + const context = kiloclawInstanceContext(sandboxId); const results = await Promise.allSettled( humanMemberIds.map(userId => es.pushEvent(userId, context, event, payload)) diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index db5373ac8e..884b31de1e 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -10,11 +10,13 @@ import type { ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; +import { contentBlocksToText } from '../util/content'; import { extractConversationContext, pushEventToHumanMembers, pushInstanceEvent, } from './event-push'; +import { fetchSandboxLabel } from './sandbox-lookup'; import type { ConversationInfo } from '../do/conversation-do'; export type DeferCtx = { waitUntil: (p: Promise) => void }; @@ -304,6 +306,32 @@ async function postCommitFanOut( } await Promise.allSettled(instanceEvents); } + + // ── Block E: Push notification fanout ───────────────────────────────── + // Runs after realtime/event-service delivery has been attempted. Sender is + // excluded; bot members are not push recipients (kind=bot, never in + // humanMemberIds). Failures are logged but never propagate — the send has + // already succeeded and any other post-commit work must complete. + const pushRecipients = humanMemberIds.filter(id => id !== callerId); + if (sandboxId !== null && pushRecipients.length > 0) { + try { + const senderUserId = isSenderHuman ? callerId : null; + const bodyPreview = contentBlocksToText(content).slice(0, 200); + const sandboxLabel = await fetchSandboxLabel(env.HYPERDRIVE.connectionString, sandboxId); + const conversationTitle = info.title ?? autoTitle ?? 'Untitled'; + await env.NOTIFICATIONS.sendPushForConversation({ + conversationId, + sandboxId, + senderUserId, + recipientUserIds: pushRecipients, + title: `${sandboxLabel} · ${conversationTitle}`, + bodyPreview, + messageId, + }); + } catch (err) { + logger.error('sendPushForConversation failed', formatError(err)); + } + } } // ─── editMessage ──────────────────────────────────────────────────────────── diff --git a/services/kilo-chat/src/services/sandbox-lookup.ts b/services/kilo-chat/src/services/sandbox-lookup.ts new file mode 100644 index 0000000000..7dfad7055b --- /dev/null +++ b/services/kilo-chat/src/services/sandbox-lookup.ts @@ -0,0 +1,18 @@ +import { getWorkerDb } from '@kilocode/db/client'; +import { kiloclaw_instances } from '@kilocode/db/schema'; +import { and, eq, isNull } from 'drizzle-orm'; + +export async function fetchSandboxLabel( + hyperdriveConnectionString: string, + sandboxId: string +): Promise { + const db = getWorkerDb(hyperdriveConnectionString); + const [row] = await db + .select({ name: kiloclaw_instances.name }) + .from(kiloclaw_instances) + .where( + and(eq(kiloclaw_instances.sandbox_id, sandboxId), isNull(kiloclaw_instances.destroyed_at)) + ) + .limit(1); + return row?.name ?? 'KiloClaw'; +} diff --git a/services/kilo-chat/src/util/content.ts b/services/kilo-chat/src/util/content.ts new file mode 100644 index 0000000000..060e75726a --- /dev/null +++ b/services/kilo-chat/src/util/content.ts @@ -0,0 +1,12 @@ +import type { ContentBlock } from '@kilocode/kilo-chat'; + +/** Concatenates text content blocks into a single string. Skips non-text blocks. */ +export function contentBlocksToText(content: ContentBlock[]): string { + return content + .filter( + (b): b is { type: 'text'; text: string } => + b.type === 'text' && typeof (b as { text?: unknown }).text === 'string' + ) + .map(b => b.text) + .join(''); +} diff --git a/services/kilo-chat/vitest.config.mts b/services/kilo-chat/vitest.config.mts index 7f657f3298..f814c84e0e 100644 --- a/services/kilo-chat/vitest.config.mts +++ b/services/kilo-chat/vitest.config.mts @@ -20,6 +20,7 @@ export default defineWorkersConfig({ serviceBindings: { KILOCLAW: 'kiloclaw-stub', EVENT_SERVICE: 'event-service-stub', + NOTIFICATIONS: 'notifications-stub', KILO_CHAT_SELF: kCurrentWorker as unknown as string, }, workers: [ @@ -59,6 +60,21 @@ export default defineWorkersConfig({ } `, }, + { + name: 'notifications-stub', + modules: true, + script: ` + import { WorkerEntrypoint } from 'cloudflare:workers'; + export default class NotificationsStub extends WorkerEntrypoint { + async fetch(request) { + return new Response('ok'); + } + async sendPushForConversation(input) { + return { perRecipient: [] }; + } + } + `, + }, ], }, }, diff --git a/services/kilo-chat/worker-configuration.d.ts b/services/kilo-chat/worker-configuration.d.ts index 37bd73ea62..425c447524 100644 --- a/services/kilo-chat/worker-configuration.d.ts +++ b/services/kilo-chat/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 9175d354bbd6fb2004e44ec97e77c151) +// Generated by Wrangler by running `wrangler types` (hash: 3a963525d82eadcc7359b89d1d30e039) // Runtime types generated with workerd@1.20260312.1 2026-04-25 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -15,6 +15,7 @@ declare namespace Cloudflare { SANDBOX_STATUS_DO: DurableObjectNamespace; KILOCLAW: Fetcher /* kiloclaw */; EVENT_SERVICE: Fetcher /* event-service */; + NOTIFICATIONS: Service /* entrypoint NotificationsService from notifications */; } } interface Env extends Cloudflare.Env {} diff --git a/services/kilo-chat/wrangler.jsonc b/services/kilo-chat/wrangler.jsonc index 167e5dce38..54e5fee17f 100644 --- a/services/kilo-chat/wrangler.jsonc +++ b/services/kilo-chat/wrangler.jsonc @@ -42,6 +42,11 @@ "services": [ { "binding": "KILOCLAW", "service": "kiloclaw" }, { "binding": "EVENT_SERVICE", "service": "event-service" }, + { + "binding": "NOTIFICATIONS", + "service": "notifications", + "entrypoint": "NotificationsService", + }, ], "secrets_store_secrets": [ diff --git a/services/notifications/package.json b/services/notifications/package.json index a7897326d8..a304e241c6 100644 --- a/services/notifications/package.json +++ b/services/notifications/package.json @@ -6,7 +6,7 @@ "deploy": "wrangler deploy", "dev": "wrangler dev", "start": "wrangler dev", - "test": "vitest", + "test": "vitest run", "cf-typegen": "wrangler types", "typecheck": "tsgo --noEmit", "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/notifications/src" @@ -22,12 +22,12 @@ }, "dependencies": { "@kilocode/db": "workspace:*", + "@kilocode/event-service": "workspace:*", "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "catalog:", "expo-server-sdk": "^6.1.0", "hono": "catalog:", - "stream-chat": "catalog:", "zod": "catalog:" } } diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts new file mode 100644 index 0000000000..1e9dca7fd3 --- /dev/null +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -0,0 +1,228 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { getTableName } from 'drizzle-orm'; +import type { DispatchPushInput } from '@kilocode/notifications'; + +import { sendPushNotifications } from '../lib/expo-push'; +import * as dbClient from '@kilocode/db/client'; + +vi.mock('../lib/expo-push', () => ({ + sendPushNotifications: vi.fn(async () => ({ + ticketTokenPairs: [{ ticket: { status: 'ok', id: 't1' }, token: 'tok1' }], + staleTokens: [], + })), +})); + +type DbState = { + tokens: { user_id: string; token: string }[]; + badgeTotal: number; +}; + +function installDbMock(state: DbState) { + const fakeDb = { + select: () => ({ + from: (table: Parameters[0]) => ({ + where: async () => { + if (getTableName(table) === 'user_push_tokens') { + return state.tokens.map(t => ({ token: t.token })); + } + // sum(badge_count) — return single row with `total` + return [{ total: state.badgeTotal }]; + }, + }), + }), + insert: () => ({ + values: () => ({ onConflictDoUpdate: async () => undefined }), + }), + delete: () => ({ where: async () => undefined }), + }; + vi.spyOn(dbClient, 'getWorkerDb').mockReturnValue( + fakeDb as unknown as ReturnType + ); +} + +const baseInput = (over: Partial = {}): DispatchPushInput => ({ + userId: 'user-1', + presenceContext: '/presence/kiloclaw/sb1/conv1', + idempotencyKey: 'k1', + badge: { badgeBucket: 'conv1', delta: 1 }, + push: { + title: 'T', + body: 'B', + data: { type: 'chat.message', sandboxId: 'sb1', conversationId: 'conv1', messageId: 'm1' }, + sound: 'default', + priority: 'high', + }, + ...over, +}); + +function getDO(name = 'conv1') { + const id = env.NOTIFICATION_CHANNEL_DO.idFromName(name); + return env.NOTIFICATION_CHANNEL_DO.get(id); +} + +describe('NotificationChannelDO.dispatchPush', () => { + beforeEach(() => { + vi.mocked(sendPushNotifications).mockClear(); + vi.spyOn(env.EXPO_ACCESS_TOKEN, 'get').mockResolvedValue('test-token'); + }); + + it('returns suppressed_presence when EVENT_SERVICE.isUserInContext is true', async () => { + installDbMock({ tokens: [{ user_id: 'user-1', token: 'tok1' }], badgeTotal: 0 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(true); + const result = await getDO().dispatchPush(baseInput()); + expect(result.kind).toBe('suppressed_presence'); + expect(sendPushNotifications).not.toHaveBeenCalled(); + }); + + it('returns no_tokens when the user has no push tokens', async () => { + installDbMock({ tokens: [], badgeTotal: 0 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); + const result = await getDO().dispatchPush(baseInput({ userId: 'user-no-tokens' })); + expect(result.kind).toBe('no_tokens'); + expect(sendPushNotifications).not.toHaveBeenCalled(); + }); + + it('delivers, increments badge, writes idempotency key', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); + const result = await getDO('conv-deliver').dispatchPush( + baseInput({ idempotencyKey: 'k-deliver' }) + ); + expect(result.kind).toBe('delivered'); + expect(sendPushNotifications).toHaveBeenCalledOnce(); + const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; + expect(messages[0].badge).toBe(1); + }); + + it('returns duplicate when the idempotency key has been seen', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + const stub = getDO('conv-dup'); + const input = baseInput({ idempotencyKey: 'k-dup' }); + await stub.dispatchPush(input); + const second = await stub.dispatchPush(input); + expect(second.kind).toBe('duplicate'); + expect(sendPushNotifications).toHaveBeenCalledOnce(); + }); + + it('skips badge mutation when badge is null', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); + const result = await getDO('conv-no-badge').dispatchPush( + baseInput({ badge: null, idempotencyKey: 'k-no-badge' }) + ); + expect(result.kind).toBe('delivered'); + const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; + expect(messages[0].badge).toBeUndefined(); + }); + + it('does not write idempotency key on Expo failure', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); + const stub = getDO('conv-fail'); + const input = baseInput({ idempotencyKey: 'k-fail', badge: null }); + const first = await stub.dispatchPush(input); + expect(first.kind).toBe('failed'); + const second = await stub.dispatchPush(input); + expect(second.kind).not.toBe('duplicate'); + }); + + it('does not re-increment the badge when retrying after Expo failure', async () => { + const insertSpy = vi.fn().mockReturnValue({ + values: () => ({ onConflictDoUpdate: async () => undefined }), + }); + vi.spyOn(dbClient, 'getWorkerDb').mockReturnValue({ + select: () => ({ + from: (table: Parameters[0]) => ({ + where: async () => { + if (getTableName(table) === 'user_push_tokens') { + return [{ token: 'tok1' }]; + } + return [{ total: 1 }]; + }, + }), + }), + insert: insertSpy, + delete: () => ({ where: async () => undefined }), + } as unknown as ReturnType); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); + + const stub = getDO('conv-no-double'); + const input = baseInput({ idempotencyKey: 'k-no-double' }); + + const first = await stub.dispatchPush(input); + expect(first.kind).toBe('failed'); + expect(insertSpy).toHaveBeenCalledTimes(1); + + const second = await stub.dispatchPush(input); + expect(second.kind).toBe('delivered'); + // Badge must not be incremented twice across the retry — the first + // attempt's `pending` marker gates the second insert out. + expect(insertSpy).toHaveBeenCalledTimes(1); + }); + + it('schedules cleanup when writing the pending marker (failed send)', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); + const stub = getDO('conv-pending-alarm'); + + const result = await stub.dispatchPush(baseInput({ idempotencyKey: 'k-pending-alarm' })); + expect(result.kind).toBe('failed'); + // Even though delivery failed, an alarm must be set so the orphan + // `pending` record gets pruned after IDEM_TTL_MS. + const alarm = await runInDurableObject(stub, (_inst, state) => state.storage.getAlarm()); + expect(alarm).not.toBeNull(); + }); + + it('reschedules cleanup for younger records when alarm fires', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + const stub = getDO('conv-reschedule'); + + const now = Date.now(); + await runInDurableObject(stub, async (_inst, state) => { + await state.storage.put('idem:old', { stage: 'delivered', ts: now - 2 * 60 * 60 * 1000 }); + await state.storage.put('idem:new', { stage: 'delivered', ts: now - 30 * 60 * 1000 }); + }); + + await runInDurableObject(stub, async inst => { + await (inst as unknown as { alarm: () => Promise }).alarm(); + }); + + const remaining = await runInDurableObject(stub, async (_inst, state) => { + const entries = await state.storage.list({ prefix: 'idem:' }); + return Array.from(entries.keys()); + }); + expect(remaining).toEqual(['idem:new']); + + const alarm = await runInDurableObject(stub, (_inst, state) => state.storage.getAlarm()); + expect(alarm).not.toBeNull(); + // Should be rescheduled for the younger record's expiry, not "1h from now". + const expectedExpiry = now - 30 * 60 * 1000 + 60 * 60 * 1000; + expect(alarm).toBe(expectedExpiry); + }); + + it('does not reset the alarm on every successful send', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + const stub = getDO('conv-alarm'); + + await stub.dispatchPush(baseInput({ idempotencyKey: 'k-alarm-1' })); + const firstAlarm = await runInDurableObject(stub, (_inst, state) => state.storage.getAlarm()); + expect(firstAlarm).not.toBeNull(); + + // Advance Date.now so a naive setAlarm would push the alarm forward. + const realNow = Date.now; + try { + vi.spyOn(Date, 'now').mockImplementation(() => realNow.call(Date) + 60_000); + await stub.dispatchPush(baseInput({ idempotencyKey: 'k-alarm-2' })); + } finally { + vi.mocked(Date.now).mockRestore(); + } + const secondAlarm = await runInDurableObject(stub, (_inst, state) => state.storage.getAlarm()); + expect(secondAlarm).toBe(firstAlarm); + }); +}); diff --git a/services/notifications/src/__tests__/env.d.ts b/services/notifications/src/__tests__/env.d.ts new file mode 100644 index 0000000000..3257351c91 --- /dev/null +++ b/services/notifications/src/__tests__/env.d.ts @@ -0,0 +1,7 @@ +import type NotificationsService from '../index'; + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env { + SELF: Service; + } +} diff --git a/services/notifications/src/__tests__/send-push-for-conversation.test.ts b/services/notifications/src/__tests__/send-push-for-conversation.test.ts new file mode 100644 index 0000000000..2eb5365773 --- /dev/null +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -0,0 +1,70 @@ +import { env } from 'cloudflare:test'; +import { describe, expect, it, vi } from 'vitest'; +import type { + DispatchPushInput, + PerRecipientResult, + SendPushForConversationInput, +} from '@kilocode/notifications'; + +import type * as do_module from '../dos/NotificationChannelDO'; + +const baseInput = ( + over: Partial = {} +): SendPushForConversationInput => ({ + conversationId: 'conv1', + sandboxId: 'sb1', + senderUserId: 'sender', + recipientUserIds: ['r1', 'r2', 'r2', 'sender'], + title: 'Conv Title', + bodyPreview: 'hello', + messageId: 'm1', + ...over, +}); + +describe('NotificationsService.sendPushForConversation', () => { + it('excludes sender, dedupes, fans out to remaining recipients', async () => { + const stubSpy = vi.fn(async (_input: DispatchPushInput) => ({ + kind: 'delivered' as const, + tokenCount: 1, + })); + vi.spyOn(env.NOTIFICATION_CHANNEL_DO, 'get').mockReturnValue({ + dispatchPush: stubSpy, + } as unknown as DurableObjectStub); + + const result = await env.SELF.sendPushForConversation(baseInput()); + + expect(stubSpy).toHaveBeenCalledTimes(2); // r1, r2 + expect(result.perRecipient.map((r: PerRecipientResult) => r.userId).sort()).toEqual([ + 'r1', + 'r2', + ]); + expect(result.perRecipient.every((r: PerRecipientResult) => r.outcome === 'delivered')).toBe( + true + ); + }); + + it('passes the right presence context and badge bucket', async () => { + const stubSpy = vi.fn(async (_input: DispatchPushInput) => ({ + kind: 'delivered' as const, + tokenCount: 1, + })); + vi.spyOn(env.NOTIFICATION_CHANNEL_DO, 'get').mockReturnValue({ + dispatchPush: stubSpy, + } as unknown as DurableObjectStub); + + await env.SELF.sendPushForConversation( + baseInput({ recipientUserIds: ['r1'], senderUserId: null }) + ); + const firstCall = stubSpy.mock.calls[0]; + if (!firstCall) throw new Error('expected dispatchPush to be called'); + const call: DispatchPushInput = firstCall[0]; + expect(call.presenceContext).toBe('/presence/kiloclaw/sb1/conv1'); + expect(call.badge).toEqual({ badgeBucket: 'kiloclaw:sb1:conv1', delta: 1 }); + expect(call.push.data).toEqual({ + type: 'chat.message', + sandboxId: 'sb1', + conversationId: 'conv1', + messageId: 'm1', + }); + }); +}); diff --git a/services/notifications/src/__tests__/setup.ts b/services/notifications/src/__tests__/setup.ts new file mode 100644 index 0000000000..d54904d430 --- /dev/null +++ b/services/notifications/src/__tests__/setup.ts @@ -0,0 +1,17 @@ +import { vi } from 'vitest'; + +vi.mock('@kilocode/db/client', () => ({ + getWorkerDb: () => ({ + select: () => ({ + from: (table: { _: { name: string } }) => ({ + where: () => { + if (table._.name === 'user_push_tokens') return []; + if (table._.name === 'badge_counts') return [{ total: 0 }]; + return []; + }, + }), + }), + insert: () => ({ values: () => ({ onConflictDoUpdate: async () => undefined }) }), + delete: () => ({ where: async () => undefined }), + }), +})); diff --git a/services/notifications/src/bindings.d.ts b/services/notifications/src/bindings.d.ts new file mode 100644 index 0000000000..5797e9eaf4 --- /dev/null +++ b/services/notifications/src/bindings.d.ts @@ -0,0 +1,14 @@ +import type {} from './worker-configuration.d.ts'; + +// Augment the wrangler-generated Env with RPC method signatures for service +// bindings. `worker-configuration.d.ts` types these as plain Fetcher; this +// file layers on the RPC shape so call sites don't need runtime casts. +declare global { + interface Env { + EVENT_SERVICE: Fetcher & { + isUserInContext(userId: string, context: string): Promise; + }; + } +} + +export type NotificationsEnv = Env; diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 3568f9a8e7..195bdd82e9 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,210 +1,151 @@ import { DurableObject } from 'cloudflare:workers'; import { getWorkerDb } from '@kilocode/db/client'; -import { badge_counts, kiloclaw_instances, user_push_tokens } from '@kilocode/db/schema'; -import { badgeBucketForInstance } from '@kilocode/notifications'; -import { and, eq, inArray, isNull, sql, sum } from 'drizzle-orm'; -import type { Event } from 'stream-chat'; +import { badge_counts, user_push_tokens } from '@kilocode/db/schema'; +import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/notifications'; +import { eq, inArray, sql, sum } from 'drizzle-orm'; import type { ExpoPushMessage, TicketTokenPair } from '../lib/expo-push'; import { sendPushNotifications } from '../lib/expo-push'; -type ReceiptCheckMessage = { - ticketTokenPairs: TicketTokenPair[]; -}; +type ReceiptCheckMessage = { ticketTokenPairs: TicketTokenPair[] }; -type PendingMessage = { - messageId: string; - senderId: string; - text: string; - notified: boolean; - createdAt: number; - updatedAt: string; // ISO timestamp from Stream Chat payload -}; +// Two-stage idempotency record. `pending` means the badge was incremented +// for this idempotency key but the Expo send did not (yet) succeed; on +// retry we must skip the increment to avoid double-counting. `delivered` +// means the send succeeded; further attempts are duplicates. +type IdemRecord = { stage: 'pending' | 'delivered'; ts: number }; -const DEDUP_PREFIX = 'dedup:'; -const MSG_PREFIX = 'msg:'; -const DEDUP_TTL_MS = 60 * 60 * 1000; // 1 hour -const DEBOUNCE_MS = 10_000; // 10 seconds +const IDEM_PREFIX = 'idem:'; +const IDEM_TTL_MS = 60 * 60 * 1000; // 1 hour export class NotificationChannelDO extends DurableObject { - async processWebhook(payload: Event, webhookId: string): Promise { - // Webhook-level dedup (prevents reprocessing the same delivery) - const existing = await this.ctx.storage.get(`${DEDUP_PREFIX}${webhookId}`); - if (existing) { - return Response.json({ ok: true, deduplicated: true }); - } - await this.markWebhookSeen(webhookId); - - const messageId = payload.message?.id; - const senderId = payload.message?.user?.id; - const messageText = payload.message?.text ?? ''; - const messageUpdatedAt = payload.message?.updated_at ?? payload.created_at ?? ''; - - if (!messageId || !senderId?.startsWith('bot-')) { - return Response.json({ ok: true }); - } - - const msgKey = `${MSG_PREFIX}${messageId}`; - const pendingMessage = await this.ctx.storage.get(msgKey); - - if (pendingMessage?.notified) { - return Response.json({ ok: true }); - } + async dispatchPush(input: DispatchPushInput): Promise { + // 1. Idempotency. DO is single-threaded — requests for a given + // conversation serialize on this instance. A `failed` outcome + // leaves the record at `pending` so upstream can retry the send + // without re-incrementing the badge. + const idemKey = `${IDEM_PREFIX}${input.idempotencyKey}`; + const existing = await this.ctx.storage.get(idemKey); + if (existing?.stage === 'delivered') return { kind: 'duplicate' }; + const isRetry = existing?.stage === 'pending'; + + // 2. Presence + const inContext = await this.env.EVENT_SERVICE.isUserInContext( + input.userId, + input.presenceContext + ); + if (inContext) return { kind: 'suppressed_presence' }; - if (pendingMessage) { - // Only accept if this event is newer than what we have - if (messageUpdatedAt <= pendingMessage.updatedAt) { - return Response.json({ ok: true }); - } - if (messageText) { - pendingMessage.text = messageText; - } - pendingMessage.updatedAt = messageUpdatedAt; - await this.ctx.storage.put(msgKey, pendingMessage); - await this.scheduleAlarm(DEBOUNCE_MS); - } else { - // First event for this message (could be message.new or a late message.updated) - const pending: PendingMessage = { - messageId, - senderId, - text: messageText, - notified: false, - createdAt: Date.now(), - updatedAt: messageUpdatedAt, - }; - await this.ctx.storage.put(msgKey, pending); - await this.scheduleAlarm(DEBOUNCE_MS); - } - - return Response.json({ ok: true }); - } - - override async alarm(): Promise { - // Prune expired dedup entries - const dedupEntries = await this.ctx.storage.list({ prefix: DEDUP_PREFIX }); - const now = Date.now(); - const expired: string[] = []; - for (const [key, timestamp] of dedupEntries) { - if (now - timestamp > DEDUP_TTL_MS) { - expired.push(key); - } - } - if (expired.length > 0) { - await this.ctx.storage.delete(expired); - } - - // Process pending messages that have debounced - const pendingEntries = await this.ctx.storage.list({ prefix: MSG_PREFIX }); - for (const [key, msg] of pendingEntries) { - if (msg.notified) { - // Clean up old notified messages - if (now - msg.createdAt > DEDUP_TTL_MS) { - await this.ctx.storage.delete(key); - } - continue; - } - - if (!msg.text) { - // No text — nothing to notify about, discard - await this.ctx.storage.delete(key); - continue; - } - - await this.sendNotification(msg); - msg.notified = true; - await this.ctx.storage.put(key, msg); - } - } - - private async sendNotification(msg: PendingMessage): Promise { - const sandboxId = msg.senderId.slice(4); const db = getWorkerDb(this.env.HYPERDRIVE.connectionString); - const [instance] = await db - .select({ - id: kiloclaw_instances.id, - user_id: kiloclaw_instances.user_id, - name: kiloclaw_instances.name, - }) - .from(kiloclaw_instances) - .where( - and(eq(kiloclaw_instances.sandbox_id, sandboxId), isNull(kiloclaw_instances.destroyed_at)) - ) - .limit(1); - - if (!instance) { - return; - } - - // Increment the badge count for this bucket and return the new total across all buckets. - // Done before the token guard so unread state is always persisted even if the user - // temporarily has no registered push tokens (e.g. between reinstalls). - // Uses UPSERT so the row is created on first notification for this bucket. - const badgeBucket = badgeBucketForInstance(sandboxId); - await db - .insert(badge_counts) - .values({ user_id: instance.user_id, badge_bucket: badgeBucket, badge_count: 1 }) - .onConflictDoUpdate({ - target: [badge_counts.user_id, badge_counts.badge_bucket], - set: { badge_count: sql`${badge_counts.badge_count} + 1` }, - }); - - const [totals] = await db - .select({ total: sum(badge_counts.badge_count) }) - .from(badge_counts) - .where(eq(badge_counts.user_id, instance.user_id)); - - const badgeCount = Number(totals?.total ?? 0); - + // 3. Tokens const tokens = await db .select({ token: user_push_tokens.token }) .from(user_push_tokens) - .where(eq(user_push_tokens.user_id, instance.user_id)); - - if (tokens.length === 0) { - return; + .where(eq(user_push_tokens.user_id, input.userId)); + + if (tokens.length === 0) return { kind: 'no_tokens' }; + + // 4. Badge math. On a retry the badge was already incremented during + // the prior attempt; re-applying the delta would double-count. + // The total is recomputed in either case (other writers may have + // advanced it). + let badgeTotal: number | undefined; + if (input.badge) { + if (!isRetry) { + // Mark `pending` BEFORE the increment so any later failure path + // is gated on the marker and a retry skips the increment. + const ts = Date.now(); + await this.ctx.storage.put(idemKey, { stage: 'pending', ts }); + // Also schedule cleanup at this point — if Expo keeps failing and + // no future push ever lands, `pending` would otherwise leak. + await this.ensureCleanupAlarm(ts); + await db + .insert(badge_counts) + .values({ + user_id: input.userId, + badge_bucket: input.badge.badgeBucket, + badge_count: input.badge.delta, + }) + .onConflictDoUpdate({ + target: [badge_counts.user_id, badge_counts.badge_bucket], + set: { badge_count: sql`${badge_counts.badge_count} + ${input.badge.delta}` }, + }); + } + const [totals] = await db + .select({ total: sum(badge_counts.badge_count) }) + .from(badge_counts) + .where(eq(badge_counts.user_id, input.userId)); + badgeTotal = Number(totals?.total ?? 0); } - const truncatedMessage = msg.text.length > 100 ? msg.text.slice(0, 97) + '...' : msg.text; - + // 5. Send via Expo const messages: ExpoPushMessage[] = tokens.map(({ token }) => ({ to: token, - title: instance.name ?? 'KiloClaw', - body: truncatedMessage, - // Keep in sync with NotificationData in apps/mobile/src/lib/notifications.ts - data: { type: 'chat', instanceId: sandboxId }, - badge: badgeCount, - sound: 'default' as const, - priority: 'high' as const, + title: input.push.title, + body: input.push.body, + data: input.push.data, + ...(badgeTotal !== undefined && { badge: badgeTotal }), + sound: input.push.sound ?? undefined, + priority: input.push.priority ?? 'default', })); const accessToken = await this.env.EXPO_ACCESS_TOKEN.get(); - const { ticketTokenPairs, staleTokens } = await sendPushNotifications(messages, accessToken); + let result: { ticketTokenPairs: TicketTokenPair[]; staleTokens: string[] }; + try { + result = await sendPushNotifications(messages, accessToken); + } catch (err) { + // Leave any `pending` marker in place — retries will re-attempt the + // send while skipping the badge increment. + return { + kind: 'failed', + error: err instanceof Error ? err.message : String(err), + }; + } - if (staleTokens.length > 0) { - await db.delete(user_push_tokens).where(inArray(user_push_tokens.token, staleTokens)); + if (result.staleTokens.length > 0) { + await db.delete(user_push_tokens).where(inArray(user_push_tokens.token, result.staleTokens)); } - if (ticketTokenPairs.length > 0) { - const receiptMsg: ReceiptCheckMessage = { ticketTokenPairs }; + if (result.ticketTokenPairs.length > 0) { + const receiptMsg: ReceiptCheckMessage = { ticketTokenPairs: result.ticketTokenPairs }; await this.env.RECEIPTS_QUEUE.send(receiptMsg, { delaySeconds: 900 }); } - } - private async markWebhookSeen(webhookId: string): Promise { - await this.ctx.storage.put(`${DEDUP_PREFIX}${webhookId}`, Date.now()); + // 6. Mark `delivered` so future retries short-circuit as duplicate. + const ts = Date.now(); + await this.ctx.storage.put(idemKey, { stage: 'delivered', ts }); + await this.ensureCleanupAlarm(ts); + + return { kind: 'delivered', tokenCount: tokens.length }; } - private async scheduleAlarm(delayMs: number): Promise { - // Always reset the alarm to the new debounce window - await this.ctx.storage.setAlarm(Date.now() + delayMs); + override async alarm(): Promise { + const now = Date.now(); + const entries = await this.ctx.storage.list({ prefix: IDEM_PREFIX }); + const expired: string[] = []; + let earliestRemaining: number | undefined; + for (const [key, rec] of entries) { + if (now - rec.ts > IDEM_TTL_MS) { + expired.push(key); + } else if (earliestRemaining === undefined || rec.ts < earliestRemaining) { + earliestRemaining = rec.ts; + } + } + if (expired.length > 0) await this.ctx.storage.delete(expired); + // Reschedule for the earliest remaining record so a quiet conversation + // still gets its leftover entries pruned exactly once their TTL elapses. + if (earliestRemaining !== undefined) { + await this.ctx.storage.setAlarm(earliestRemaining + IDEM_TTL_MS); + } } -} -export function getNotificationChannelDO( - env: Env, - channelId: string -): DurableObjectStub { - const id = env.NOTIFICATION_CHANNEL_DO.idFromName(channelId); - return env.NOTIFICATION_CHANNEL_DO.get(id) as DurableObjectStub; + // Schedule cleanup `IDEM_TTL_MS` from `refTs` only if no alarm is pending. + // `setAlarm` replaces any existing alarm; calling it unconditionally would + // push cleanup forward indefinitely on a busy conversation. + private async ensureCleanupAlarm(refTs: number): Promise { + if ((await this.ctx.storage.getAlarm()) === null) { + await this.ctx.storage.setAlarm(refTs + IDEM_TTL_MS); + } + } } diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 231df770e7..bb4f673346 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -1,14 +1,91 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; import { Hono } from 'hono'; +import { presenceContextForConversation } from '@kilocode/event-service'; +import { + badgeBucketForConversation, + type DispatchPushInput, + type DispatchPushOutcome, + type PerRecipientResult, + type SendPushForConversationInput, + type SendPushForConversationOutput, +} from '@kilocode/notifications'; + import { queue } from './queue-consumer'; -import { webhooks } from './routes/webhooks'; export { NotificationChannelDO } from './dos/NotificationChannelDO'; const app = new Hono<{ Bindings: Env }>(); +app.get('/', c => c.json({ ok: true })); -app.route('/webhooks', webhooks); +type ConversationDOStub = { + dispatchPush: (input: DispatchPushInput) => Promise; +}; -app.get('/', c => c.json({ ok: true })); +/** Pure core for unit testability. */ +export async function sendPushForConversationCore( + input: SendPushForConversationInput, + deps: { + getConversationDOStub: (conversationId: string) => ConversationDOStub; + } +): Promise { + const recipients: string[] = []; + const seen = new Set(); + for (const id of input.recipientUserIds) { + if (id === input.senderUserId) continue; + if (seen.has(id)) continue; + seen.add(id); + recipients.push(id); + } + + const perRecipient: PerRecipientResult[] = []; + for (const userId of recipients) { + const stub = deps.getConversationDOStub(input.conversationId); + const outcome = await stub.dispatchPush({ + userId, + presenceContext: presenceContextForConversation(input.sandboxId, input.conversationId), + idempotencyKey: `chat:${input.messageId}:${userId}`, + badge: { + badgeBucket: badgeBucketForConversation(input.sandboxId, input.conversationId), + delta: 1, + }, + push: { + title: input.title, + body: input.bodyPreview, + data: { + type: 'chat.message', + sandboxId: input.sandboxId, + conversationId: input.conversationId, + messageId: input.messageId, + }, + sound: 'default', + priority: 'high', + }, + }); + perRecipient.push({ userId, outcome: outcome.kind }); + } + return { perRecipient }; +} + +export class NotificationsService extends WorkerEntrypoint { + override async fetch(request: Request): Promise { + return app.fetch(request, this.env, this.ctx); + } + + override async queue(batch: MessageBatch): Promise { + return queue(batch as Parameters[0], this.env); + } + + async sendPushForConversation( + input: SendPushForConversationInput + ): Promise { + return sendPushForConversationCore(input, { + getConversationDOStub: (conversationId: string) => + this.env.NOTIFICATION_CHANNEL_DO.get( + this.env.NOTIFICATION_CHANNEL_DO.idFromName(conversationId) + ) as unknown as ConversationDOStub, + }); + } +} -export default { fetch: app.fetch, queue }; +export default NotificationsService; diff --git a/services/notifications/src/routes/webhooks.ts b/services/notifications/src/routes/webhooks.ts deleted file mode 100644 index dc66a30d2d..0000000000 --- a/services/notifications/src/routes/webhooks.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; -import { Hono } from 'hono'; -import type { Event } from 'stream-chat'; - -import { getNotificationChannelDO } from '../dos/NotificationChannelDO'; - -const webhooks = new Hono<{ Bindings: Env }>(); - -function verifyWebhookSignature(body: string, signature: string | null, secret: string): boolean { - if (!signature) return false; - - const expectedSignature = createHmac('sha256', secret).update(body).digest('hex'); - - if (signature.length !== expectedSignature.length) return false; - return timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); -} - -webhooks.post('/stream-chat', async c => { - const rawBody = await c.req.text(); - const signature = c.req.header('x-signature') ?? null; - const webhookId = c.req.header('x-webhook-id'); - - const secret = await c.env.STREAM_CHAT_API_SECRET.get(); - if (!verifyWebhookSignature(rawBody, signature, secret)) { - return c.json({ error: 'Invalid signature' }, 401); - } - - const payload = JSON.parse(rawBody) as Event; - - // Only handle new and updated messages - if (payload.type !== 'message.new' && payload.type !== 'message.updated') { - return c.json({ ok: true }); - } - - const channelId = payload.channel_id; - if (!channelId || !webhookId) { - return c.json({ ok: true }); - } - - // Forward to the channel's Durable Object for dedup + delivery - const stub = getNotificationChannelDO(c.env, channelId); - return stub.processWebhook(payload, webhookId); -}); - -export { webhooks }; diff --git a/services/notifications/tsconfig.json b/services/notifications/tsconfig.json index 635e98f321..71b42aebcf 100644 --- a/services/notifications/tsconfig.json +++ b/services/notifications/tsconfig.json @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, - "types": ["./worker-configuration.d.ts", "node"] + "types": ["./worker-configuration.d.ts", "node", "@cloudflare/vitest-pool-workers"] }, "exclude": ["test"], "include": ["worker-configuration.d.ts", "src/**/*.ts"] diff --git a/services/notifications/vitest.config.mts b/services/notifications/vitest.config.mts index d9430c7554..d386792f55 100644 --- a/services/notifications/vitest.config.mts +++ b/services/notifications/vitest.config.mts @@ -1,10 +1,33 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; +const kCurrentWorker = Symbol.for('miniflare.kCurrentWorker'); + export default defineWorkersConfig({ test: { + passWithNoTests: true, + setupFiles: ['./src/__tests__/setup.ts'], poolOptions: { workers: { wrangler: { configPath: './wrangler.jsonc' }, + miniflare: { + serviceBindings: { + EVENT_SERVICE: 'event-service-stub', + SELF: kCurrentWorker as unknown as string, + }, + workers: [ + { + name: 'event-service-stub', + modules: true, + script: ` + import { WorkerEntrypoint } from 'cloudflare:workers'; + export default class EventServiceStub extends WorkerEntrypoint { + async fetch() { return new Response('ok'); } + async isUserInContext() { return false; } + } + `, + }, + ], + }, }, }, }, diff --git a/services/notifications/worker-configuration.d.ts b/services/notifications/worker-configuration.d.ts index cbc9201506..0e8a5f8948 100644 --- a/services/notifications/worker-configuration.d.ts +++ b/services/notifications/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b336c1c1e874405e99f5e26c8c9319df) +// Generated by Wrangler by running `wrangler types` (hash: 35f3a1e5a589a3db24bda461e9af3ff0) // Runtime types generated with workerd@1.20260312.1 2026-02-01 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -9,9 +9,9 @@ declare namespace Cloudflare { interface Env { HYPERDRIVE: Hyperdrive; RECEIPTS_QUEUE: Queue; - STREAM_CHAT_API_SECRET: SecretsStoreSecret; EXPO_ACCESS_TOKEN: SecretsStoreSecret; - NOTIFICATION_CHANNEL_DO: DurableObjectNamespace /* NotificationChannelDO */; + NOTIFICATION_CHANNEL_DO: DurableObjectNamespace; + EVENT_SERVICE: Fetcher /* event-service */; } } interface Env extends Cloudflare.Env {} diff --git a/services/notifications/wrangler.jsonc b/services/notifications/wrangler.jsonc index 943bd8176a..ab9c249bc5 100644 --- a/services/notifications/wrangler.jsonc +++ b/services/notifications/wrangler.jsonc @@ -50,12 +50,14 @@ ], }, - "secrets_store_secrets": [ + "services": [ { - "binding": "STREAM_CHAT_API_SECRET", - "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", - "secret_name": "STREAM_CHAT_API_SECRET", + "binding": "EVENT_SERVICE", + "service": "event-service", }, + ], + + "secrets_store_secrets": [ { "binding": "EXPO_ACCESS_TOKEN", "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", From 95a44c289066a74821ed86d1948891af0341d9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:05:52 +0200 Subject: [PATCH 011/289] =?UTF-8?q?feat(web):=20PR=204=20=E2=80=94=20kilo-?= =?UTF-8?q?chat=20web=20wiring=20+=203-tier=20presence=20(#2924)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): re-check destroyed after token fetch connect() awaits getToken() before constructing the WebSocket. If disconnect() runs in that window (provider unmount, sign-out, strict-mode remount) the in-flight token fetch resolves and we'd construct a fresh socket + start the ping timer with no React owner left to clean it up. Re-check this.destroyed after the await and bail before creating the socket. --- .../PersonalInstancePresenceMount.tsx | 10 +++ .../kilo-chat/components/KiloChatLayout.tsx | 15 ++-- .../claw/kilo-chat/components/MessageArea.tsx | 27 +++++-- .../kilo-chat/components/kiloChatContext.ts | 1 - .../claw/kilo-chat/hooks/useEventService.ts | 70 ------------------ .../(app)/claw/kilo-chat/hooks/useMessages.ts | 3 +- .../src/app/(app)/claw/kilo-chat/layout.tsx | 5 -- apps/web/src/app/(app)/claw/layout.tsx | 2 + .../components/PlatformPresenceMount.tsx | 8 +++ apps/web/src/app/(app)/layout.tsx | 29 ++++---- .../components/OrgInstancePresenceMount.tsx | 13 ++++ .../[id]/claw/kilo-chat/layout.tsx | 5 -- .../(app)/organizations/[id]/claw/layout.tsx | 2 + apps/web/src/contexts/EventServiceContext.tsx | 72 +++++++++++++++++++ apps/web/src/hooks/useDocumentVisible.ts | 19 +++++ apps/web/src/hooks/useInstancePresence.ts | 14 ++++ apps/web/src/hooks/usePlatformPresence.ts | 11 +++ apps/web/src/hooks/usePresenceSubscription.ts | 23 ++++++ apps/web/src/routers/kilo-chat-router.test.ts | 39 ++++++++++ apps/web/src/routers/kilo-chat-router.ts | 20 ++++++ apps/web/src/routers/root-router.ts | 2 + packages/event-service/src/client.ts | 4 ++ 22 files changed, 289 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx delete mode 100644 apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts create mode 100644 apps/web/src/app/(app)/components/PlatformPresenceMount.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx create mode 100644 apps/web/src/contexts/EventServiceContext.tsx create mode 100644 apps/web/src/hooks/useDocumentVisible.ts create mode 100644 apps/web/src/hooks/useInstancePresence.ts create mode 100644 apps/web/src/hooks/usePlatformPresence.ts create mode 100644 apps/web/src/hooks/usePresenceSubscription.ts create mode 100644 apps/web/src/routers/kilo-chat-router.test.ts create mode 100644 apps/web/src/routers/kilo-chat-router.ts diff --git a/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx new file mode 100644 index 0000000000..d1236318ae --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useInstancePresence } from '@/hooks/useInstancePresence'; +import { useKiloClawStatus } from '@/hooks/useKiloClaw'; + +export function PersonalInstancePresenceMount() { + const { data: status } = useKiloClawStatus(); + useInstancePresence(status?.sandboxId ?? undefined); + return null; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 8ab3d6b2eb..ab8933d548 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -7,7 +7,9 @@ import { useQueryClient } from '@tanstack/react-query'; import { formatKiloChatError } from '@kilocode/kilo-chat'; import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; -import { useEventService, useInstanceContext } from '../hooks/useEventService'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; import { useConversations, useCreateConversation, @@ -20,7 +22,6 @@ import { // ── Layout component ──────────────────────────────────────────────── type KiloChatLayoutProps = { - getToken: () => Promise; currentUserId: string; sandboxId: string | null; basePath: string; @@ -32,7 +33,6 @@ type KiloChatLayoutProps = { }; export function KiloChatLayout({ - getToken, currentUserId, sandboxId, basePath, @@ -44,8 +44,11 @@ export function KiloChatLayout({ }: KiloChatLayoutProps) { const router = useRouter(); - const { eventService, kiloChatClient } = useEventService(getToken); - useInstanceContext(eventService, sandboxId); + const { eventService, kiloChatClient } = useEventServiceClient(); + usePresenceSubscription( + sandboxId ? kiloclawInstanceContext(sandboxId) : null, + Boolean(sandboxId) + ); const queryClient = useQueryClient(); const params = useParams<{ conversationId?: string }>(); @@ -189,7 +192,6 @@ export function KiloChatLayout({ const contextValue = useMemo( () => ({ - getToken, currentUserId, instanceStatus, leavingConversationId, @@ -202,7 +204,6 @@ export function KiloChatLayout({ kiloChatClient, }), [ - getToken, currentUserId, instanceStatus, leavingConversationId, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 66beb8b8c7..e8d373a230 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -14,7 +14,12 @@ import { useRemoveReaction, useExecuteAction, } from '../hooks/useMessages'; -import { useConversationContext } from '../hooks/useEventService'; +import { + kiloclawConversationContext, + presenceContextForConversation, +} from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; +import { useDocumentVisible } from '@/hooks/useDocumentVisible'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { useConversationDetail, @@ -75,13 +80,27 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const [isRenamingTitle, setIsRenamingTitle] = useState(false); const [renameText, setRenameText] = useState(''); - // Subscribe to this conversation's events via the event-service WebSocket - useConversationContext(eventService, sandboxId, conversationId); + const visible = useDocumentVisible(); + + // Subscribe to this conversation's chat-event stream while the conversation + // is open. Not gated on visibility — we want incoming messages to land in + // the cache even when the tab is hidden. + usePresenceSubscription( + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) + ); + + // Signal our own presence on this conversation. Gated on visibility so we + // only appear "viewing" while the tab is actually in the foreground. + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) && visible + ); // Event Service delivers subscribed contexts to every handler, so each // handler must validate the incoming `ctx` against this string before // applying changes to the active conversation's state. - const expectedContext = sandboxId ? `/kiloclaw/${sandboxId}/${conversationId}` : null; + const expectedContext = sandboxId ? kiloclawConversationContext(sandboxId, conversationId) : null; const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useMessages( kiloChatClient, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts index eb7154a26b..823296599f 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts @@ -5,7 +5,6 @@ import type { EventServiceClient } from '@kilocode/event-service'; import type { KiloChatClient } from '@kilocode/kilo-chat'; export type KiloChatContextValue = { - getToken: () => Promise; currentUserId: string; instanceStatus: string | null; leavingConversationId: string | null; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts deleted file mode 100644 index 627dc60a33..0000000000 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { EventServiceClient } from '@kilocode/event-service'; -import { KiloChatClient } from '@kilocode/kilo-chat'; -import { KILO_CHAT_URL, EVENT_SERVICE_URL } from '@/lib/constants'; -import { clearKiloChatToken } from '../token'; - -/** - * Creates and manages the EventServiceClient + KiloChatClient singleton. - * Connects the WebSocket on mount, disconnects on unmount. - * Returns the clients for use by child hooks. - */ -export function useEventService(getToken: () => Promise) { - const eventService = useMemo( - () => - new EventServiceClient({ - url: EVENT_SERVICE_URL, - getToken, - // Event Service rejected our token as 401/403. Drop the cached - // token so the next request refetches; the socket is permanently - // stopped by the client to avoid a reconnect storm. - onUnauthorized: () => { - clearKiloChatToken(); - }, - }), - [getToken] - ); - - const kiloChatClient = useMemo( - () => new KiloChatClient({ eventService, baseUrl: KILO_CHAT_URL, getToken }), - [eventService, getToken] - ); - - // Connect on mount, disconnect on unmount - useEffect(() => { - void eventService.connect(); - return () => eventService.disconnect(); - }, [eventService]); - - return { eventService, kiloChatClient }; -} - -/** - * Subscribes to the instance-level context (`/kiloclaw/{sandboxId}`). - * Used at the layout level for cross-conversation events (future: unread counts). - */ -export function useInstanceContext(eventService: EventServiceClient, sandboxId: string | null) { - useEffect(() => { - if (!sandboxId) return; - const context = `/kiloclaw/${sandboxId}`; - eventService.subscribe([context]); - return () => eventService.unsubscribe([context]); - }, [eventService, sandboxId]); -} - -/** - * Subscribes to the conversation-level context (`/kiloclaw/{sandboxId}/{conversationId}`). - * Used in MessageArea for message/typing/reaction events. - */ -export function useConversationContext( - eventService: EventServiceClient, - sandboxId: string | null, - conversationId: string | null -) { - useEffect(() => { - if (!sandboxId || !conversationId) return; - const context = `/kiloclaw/${sandboxId}/${conversationId}`; - eventService.subscribe([context]); - return () => eventService.unsubscribe([context]); - }, [eventService, sandboxId, conversationId]); -} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts index f21e165e74..de70a427c5 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts @@ -16,6 +16,7 @@ import type { ExecApprovalDecision, } from '@kilocode/kilo-chat'; import { useEffect } from 'react'; +import { kiloclawConversationContext } from '@kilocode/event-service'; import { toast } from 'sonner'; const PAGE_SIZE = 50; @@ -401,7 +402,7 @@ export function useMessageCacheUpdater( useEffect(() => { if (!conversationId || !sandboxId) return; const queryKey = ['kilo-chat', 'messages', conversationId]; - const expectedContext = `/kiloclaw/${sandboxId}/${conversationId}`; + const expectedContext = kiloclawConversationContext(sandboxId, conversationId); const onCreated = (ctx: string, e: MessageCreatedEvent) => { if (ctx !== expectedContext) return; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx index 3b207995d8..242a397ed6 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx @@ -1,20 +1,15 @@ 'use client'; -import { useCallback } from 'react'; import { useUser } from '@/hooks/useUser'; import { useKiloClawStatus } from '@/hooks/useKiloClaw'; -import { getKiloChatToken } from './token'; import { KiloChatLayout } from './components/KiloChatLayout'; export default function KiloChatRootLayout({ children }: { children: React.ReactNode }) { const { data: user } = useUser(); const { data: status, isLoading } = useKiloClawStatus(); - const getToken = useCallback(() => getKiloChatToken(), []); - return ( + {children} diff --git a/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx b/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx new file mode 100644 index 0000000000..c3719aa9a6 --- /dev/null +++ b/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { usePlatformPresence } from '@/hooks/usePlatformPresence'; + +export function PlatformPresenceMount() { + usePlatformPresence(); + return null; +} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index e20158be88..afb80e8546 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -3,26 +3,31 @@ import { AppTopbar } from './components/AppTopbar'; import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; import { RoleTestingProvider } from '@/contexts/RoleTestingContext'; import { PageTitleProvider } from '@/contexts/PageTitleContext'; +import { EventServiceProvider } from '@/contexts/EventServiceContext'; import { AdminOmnibox } from '@/components/admin-omnibox'; import { PrefetchedOrganizations } from './components/PrefetchedOrganizations'; +import { PlatformPresenceMount } from './components/PlatformPresenceMount'; import { ImpactIdentify } from '@/components/ImpactIdentify'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( - - - -
- - - -
{children}
-
-
-
-
+ + + + + +
+ + + +
{children}
+
+
+
+
+
diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx new file mode 100644 index 0000000000..23c300b371 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useInstancePresence } from '@/hooks/useInstancePresence'; +import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; + +export function OrgInstancePresenceMount() { + const params = useParams<{ id: string }>(); + const organizationId = params?.id; + const { data: status } = useOrgKiloClawStatus(organizationId); + useInstancePresence(status?.sandboxId ?? undefined); + return null; +} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx index 27733df73e..8fd6f6cedf 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useCallback } from 'react'; import { useParams } from 'next/navigation'; import { useUser } from '@/hooks/useUser'; import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; -import { getKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; export default function OrgKiloChatRootLayout({ children }: { children: React.ReactNode }) { @@ -13,14 +11,11 @@ export default function OrgKiloChatRootLayout({ children }: { children: React.Re const { data: user } = useUser(); const { data: status, isLoading } = useOrgKiloClawStatus(organizationId); - const getToken = useCallback(() => getKiloChatToken(), []); - const basePath = `/organizations/${organizationId}/claw/kilo-chat`; const noInstanceRedirect = `/organizations/${organizationId}/claw/new`; return ( + {children} diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx new file mode 100644 index 0000000000..03bc458faa --- /dev/null +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react'; +import { EventServiceClient } from '@kilocode/event-service'; +import { KiloChatClient } from '@kilocode/kilo-chat'; +import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/constants'; +import { getKiloChatToken, clearKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; + +export type EventServiceContextValue = { + eventService: EventServiceClient; + kiloChatClient: KiloChatClient; +}; + +const EventServiceContext = createContext(null); + +type EventServiceProviderProps = { + children: ReactNode; +}; + +/** + * Global EventService provider — owns the single `EventServiceClient` and + * `KiloChatClient` for the authenticated app. Mounted in `(app)/layout.tsx` + * so platform-, instance-, and conversation-level presence subscriptions + * (and the kilo-chat UI) all share one WebSocket. + */ +export function EventServiceProvider({ children }: EventServiceProviderProps) { + const eventService = useMemo( + () => + new EventServiceClient({ + url: EVENT_SERVICE_URL, + getToken: getKiloChatToken, + // Event Service rejected our token as 401/403. Drop the cached + // token so the next request refetches; the socket is permanently + // stopped by the client to avoid a reconnect storm. + onUnauthorized: () => { + clearKiloChatToken(); + }, + }), + [] + ); + + const kiloChatClient = useMemo( + () => + new KiloChatClient({ + eventService, + baseUrl: KILO_CHAT_URL, + getToken: getKiloChatToken, + }), + [eventService] + ); + + // Connect on mount, disconnect on unmount. + useEffect(() => { + void eventService.connect(); + return () => eventService.disconnect(); + }, [eventService]); + + const value = useMemo( + () => ({ eventService, kiloChatClient }), + [eventService, kiloChatClient] + ); + + return {children}; +} + +export function useEventServiceClient(): EventServiceContextValue { + const ctx = useContext(EventServiceContext); + if (!ctx) { + throw new Error('useEventServiceClient must be used within an EventServiceProvider'); + } + return ctx; +} diff --git a/apps/web/src/hooks/useDocumentVisible.ts b/apps/web/src/hooks/useDocumentVisible.ts new file mode 100644 index 0000000000..df55c211cc --- /dev/null +++ b/apps/web/src/hooks/useDocumentVisible.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Returns whether the current document is visible (not hidden). + * SSR-safe: returns `true` when `document` is undefined. + */ +export function useDocumentVisible(): boolean { + const [visible, setVisible] = useState(typeof document === 'undefined' ? true : !document.hidden); + + useEffect(() => { + const onChange = () => setVisible(!document.hidden); + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); + }, []); + + return visible; +} diff --git a/apps/web/src/hooks/useInstancePresence.ts b/apps/web/src/hooks/useInstancePresence.ts new file mode 100644 index 0000000000..085e28f2e2 --- /dev/null +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -0,0 +1,14 @@ +'use client'; + +import { presenceContextForInstance } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function useInstancePresence(sandboxId: string | undefined, enabled = true) { + const visible = useDocumentVisible(); + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : null, + Boolean(sandboxId) && enabled && visible + ); +} diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts new file mode 100644 index 0000000000..86cb6fa2e8 --- /dev/null +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -0,0 +1,11 @@ +'use client'; + +import { presenceContextForPlatform } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function usePlatformPresence() { + const visible = useDocumentVisible(); + usePresenceSubscription(presenceContextForPlatform('web'), visible); +} diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts new file mode 100644 index 0000000000..56bb55a381 --- /dev/null +++ b/apps/web/src/hooks/usePresenceSubscription.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; + +/** + * Subscribes to a single presence/event-service context for the lifetime of + * the calling component. Bails out when `active` is false so callers can + * gate the subscription on, e.g., feature flags or page visibility. + * + * Reads the global `EventServiceClient` from `EventServiceProvider`, mounted + * in `(app)/layout.tsx` for every authenticated route. + */ +export function usePresenceSubscription(context: string | null, active: boolean) { + const { eventService } = useEventServiceClient(); + useEffect(() => { + if (!active || context === null) return; + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context, active]); +} diff --git a/apps/web/src/routers/kilo-chat-router.test.ts b/apps/web/src/routers/kilo-chat-router.test.ts new file mode 100644 index 0000000000..9d64775140 --- /dev/null +++ b/apps/web/src/routers/kilo-chat-router.test.ts @@ -0,0 +1,39 @@ +import jwt from 'jsonwebtoken'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; +import { JWT_TOKEN_VERSION } from '@/lib/tokens'; +import type { User } from '@kilocode/db/schema'; + +let testUser: User; + +describe('kiloChat router - getToken', () => { + beforeAll(async () => { + testUser = await insertTestUser({ + google_user_email: `kilo-chat-token-${crypto.randomUUID()}@example.com`, + google_user_name: 'Kilo Chat Token Test User', + }); + }); + + it('returns a verifiable kilo-chat JWT for the caller, expiring in ~1h', async () => { + const caller = await createCallerForUser(testUser.id); + const before = Date.now(); + const result = await caller.kiloChat.getToken(); + const after = Date.now(); + + const payload = jwt.verify(result.token, NEXTAUTH_SECRET, { + algorithms: ['HS256'], + }) as jwt.JwtPayload & { kiloUserId: string; tokenSource: string; version: number }; + + expect(payload.kiloUserId).toBe(testUser.id); + expect(payload.tokenSource).toBe('kilo-chat'); + expect(payload.version).toBe(JWT_TOKEN_VERSION); + + const expiresAtMs = Date.parse(result.expiresAt); + expect(Number.isNaN(expiresAtMs)).toBe(false); + // Router uses a 1h TTL; allow ±5s of clock slop around the call window. + const oneHourMs = 60 * 60 * 1000; + expect(expiresAtMs).toBeGreaterThanOrEqual(before + oneHourMs - 5_000); + expect(expiresAtMs).toBeLessThanOrEqual(after + oneHourMs + 5_000); + }); +}); diff --git a/apps/web/src/routers/kilo-chat-router.ts b/apps/web/src/routers/kilo-chat-router.ts new file mode 100644 index 0000000000..4b9b4e4cd5 --- /dev/null +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -0,0 +1,20 @@ +import 'server-only'; +import * as z from 'zod'; +import { generateApiToken } from '@/lib/tokens'; +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; + +const KILO_CHAT_TOKEN_TTL_S = 60 * 60; + +export const kiloChatRouter = createTRPCRouter({ + getToken: baseProcedure + .output(z.object({ token: z.string(), expiresAt: z.iso.datetime() })) + .query(({ ctx }) => { + const token = generateApiToken( + ctx.user, + { tokenSource: 'kilo-chat' }, + { expiresIn: KILO_CHAT_TOKEN_TTL_S } + ); + const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_S * 1000).toISOString(); + return { token, expiresAt }; + }), +}); diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 3380f4766f..90825e3d05 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -32,6 +32,7 @@ import { webhookTriggersRouter } from '@/routers/webhook-triggers-router'; import { userFeedbackRouter } from '@/routers/user-feedback-router'; import { appBuilderFeedbackRouter } from '@/routers/app-builder-feedback-router'; import { cloudAgentNextFeedbackRouter } from '@/routers/cloud-agent-next-feedback-router'; +import { kiloChatRouter } from '@/routers/kilo-chat-router'; import { kiloclawRouter } from '@/routers/kiloclaw-router'; import { modelsRouter } from '@/routers/models-router'; import { unifiedSessionsRouter } from '@/routers/unified-sessions-router'; @@ -69,6 +70,7 @@ export const rootRouter = createTRPCRouter({ userFeedback: userFeedbackRouter, appBuilderFeedback: appBuilderFeedbackRouter, cloudAgentNextFeedback: cloudAgentNextFeedbackRouter, + kiloChat: kiloChatRouter, kiloclaw: kiloclawRouter, models: modelsRouter, unifiedSessions: unifiedSessionsRouter, diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 9466d50b73..1bda87f200 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -103,6 +103,10 @@ export class EventServiceClient { } const token = await this.getToken(); + // disconnect() may have run while we were awaiting the token. Bail before + // creating the socket so we don't leak a WebSocket + ping timer past + // provider unmount (e.g. sign-out, navigation, strict-mode remount). + if (this.destroyed) return; const subprotocol = `${SUBPROTOCOL_PREFIX}${encodeBase64Url(token)}`; return new Promise((resolve, reject) => { From f4489ac45dbc735f09bca2e335bd77917f7cb891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:10:46 +0200 Subject: [PATCH 012/289] fix(event-service): refcount subscribe/unsubscribe by context (#2927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. --- .../src/__tests__/client.test.ts | 98 +++++++++++++++++++ packages/event-service/src/client.ts | 31 ++++-- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/packages/event-service/src/__tests__/client.test.ts b/packages/event-service/src/__tests__/client.test.ts index 9527dec426..01fe89f415 100644 --- a/packages/event-service/src/__tests__/client.test.ts +++ b/packages/event-service/src/__tests__/client.test.ts @@ -387,4 +387,102 @@ describe('EventServiceClient', () => { expect(err).toBeInstanceOf(Error); expect(err.name).toBe('HandshakeTimeoutError'); }); + + describe('subscribe/unsubscribe refcounting', () => { + function sentMessages() { + return lastMockWs.sent.map(s => JSON.parse(s) as unknown); + } + + it('only sends one wire context.subscribe when two consumers subscribe to the same context', async () => { + const client = makeClient(); + await client.connect(); + lastMockWs.sent = []; + + client.subscribe(['room:1']); + client.subscribe(['room:1']); + + expect(sentMessages()).toEqual([{ type: 'context.subscribe', contexts: ['room:1'] }]); + }); + + it('keeps the subscription alive when one of two consumers unsubscribes', async () => { + const client = makeClient(); + await client.connect(); + lastMockWs.sent = []; + + client.subscribe(['room:1']); + client.subscribe(['room:1']); + client.unsubscribe(['room:1']); + + // Only the initial 0→1 subscribe should have been sent. No unsubscribe yet + // because the second consumer is still holding a ref. + expect(sentMessages()).toEqual([{ type: 'context.subscribe', contexts: ['room:1'] }]); + }); + + it('sends context.unsubscribe only when the last consumer drops the ref (1→0)', async () => { + const client = makeClient(); + await client.connect(); + lastMockWs.sent = []; + + client.subscribe(['room:1']); + client.subscribe(['room:1']); + client.unsubscribe(['room:1']); + client.unsubscribe(['room:1']); + + expect(sentMessages()).toEqual([ + { type: 'context.subscribe', contexts: ['room:1'] }, + { type: 'context.unsubscribe', contexts: ['room:1'] }, + ]); + }); + + it('handles a mixed batch: only newly-active contexts get sent', async () => { + const client = makeClient(); + await client.connect(); + client.subscribe(['room:1']); + lastMockWs.sent = []; + + // room:1 already at refcount 1, room:2 is new. Only room:2 should hit the wire. + client.subscribe(['room:1', 'room:2']); + + expect(sentMessages()).toEqual([{ type: 'context.subscribe', contexts: ['room:2'] }]); + }); + + it('extra unsubscribes for an unknown context are no-ops', async () => { + const client = makeClient(); + await client.connect(); + lastMockWs.sent = []; + + // Never subscribed — must not crash and must not emit a wire message. + client.unsubscribe(['ghost']); + + expect(sentMessages()).toEqual([]); + }); + + it('resubscribe-on-reconnect deduplicates by context (one entry per active context)', async () => { + vi.useFakeTimers(); + const client = makeClient(); + await client.connect(); + + // Two consumers hold the same context. + client.subscribe(['room:1']); + client.subscribe(['room:1']); + + // Drop the connection — auto-reconnect kicks in. + lastMockWs.triggerClose(); + await vi.advanceTimersByTimeAsync(2000); + expect(allMockWs.length).toBe(2); + // The second mock socket also auto-triggers open via the global stub. + await vi.advanceTimersByTimeAsync(0); + + const resubMessages = allMockWs[1].sent + .map(s => JSON.parse(s) as { type: string; contexts?: string[] }) + .filter(m => m.type === 'context.subscribe'); + + // Exactly one resubscribe message containing the context exactly once, + // regardless of how many consumers hold the ref. + expect(resubMessages).toHaveLength(1); + expect(resubMessages[0]?.contexts).toEqual(['room:1']); + + vi.useRealTimers(); + }); + }); }); diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 1bda87f200..5a9490bf2d 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -48,7 +48,11 @@ export class EventServiceClient { private ws: WebSocket | null = null; private connected = false; private eventHandlers = new Map void>>(); - private activeContexts = new Set(); + // Refcounted so multiple consumers can independently subscribe to and + // unsubscribe from the same context without trampling each other. The wire + // `context.subscribe`/`context.unsubscribe` messages are only sent on the + // 0↔1 transitions; intermediate refcount churn stays client-side. + private activeContexts = new Map(); private reconnectTimer: ReturnType | null = null; private destroyed = false; private reconnectAttempts = 0; @@ -219,20 +223,31 @@ export class EventServiceClient { } subscribe(contexts: string[]): void { + const newlyActive: string[] = []; for (const ctx of contexts) { - this.activeContexts.add(ctx); + const next = (this.activeContexts.get(ctx) ?? 0) + 1; + this.activeContexts.set(ctx, next); + if (next === 1) newlyActive.push(ctx); } - if (this.isConnected()) { - this.send({ type: 'context.subscribe', contexts }); + if (newlyActive.length > 0 && this.isConnected()) { + this.send({ type: 'context.subscribe', contexts: newlyActive }); } } unsubscribe(contexts: string[]): void { + const released: string[] = []; for (const ctx of contexts) { - this.activeContexts.delete(ctx); + const current = this.activeContexts.get(ctx); + if (current === undefined) continue; + if (current <= 1) { + this.activeContexts.delete(ctx); + released.push(ctx); + } else { + this.activeContexts.set(ctx, current - 1); + } } - if (this.isConnected()) { - this.send({ type: 'context.unsubscribe', contexts }); + if (released.length > 0 && this.isConnected()) { + this.send({ type: 'context.unsubscribe', contexts: released }); } } @@ -315,7 +330,7 @@ export class EventServiceClient { if (this.activeContexts.size > 0) { this.send({ type: 'context.subscribe', - contexts: Array.from(this.activeContexts), + contexts: Array.from(this.activeContexts.keys()), }); } } From 6101b20980b1f8541bf61dd067168f3622b3b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:20:54 +0200 Subject: [PATCH 013/289] =?UTF-8?q?feat(mobile):=20PR=205a=20=E2=80=94=20k?= =?UTF-8?q?ilo-chat=20foundations=20(provider=20+=20hooks)=20(#2930)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. --- apps/mobile/package.json | 3 + apps/mobile/src/app/(app)/_layout.tsx | 129 +++++++++--------- .../kilo-chat/hooks/use-current-user-id.ts | 46 +++++++ .../kilo-chat/hooks/use-kilo-chat-client.ts | 20 +++ .../kilo-chat/hooks/use-kilo-chat-token.ts | 61 +++++++++ .../kilo-chat/kilo-chat-provider.tsx | 53 +++++++ apps/mobile/src/lib/config.ts | 3 + apps/mobile/src/lib/env-keys.js | 2 + packages/kilo-chat/src/utils.ts | 2 +- pnpm-lock.yaml | 9 ++ 10 files changed, 264 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts create mode 100644 apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3899cef255..75cea06b0a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,9 @@ "dependencies": { "@expo-google-fonts/jetbrains-mono": "^0.4.1", "@expo/react-native-action-sheet": "^4.1.1", + "@kilocode/event-service": "workspace:*", + "@kilocode/kilo-chat": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/trpc": "workspace:*", "@react-native-community/netinfo": "11.5.2", "@rn-primitives/portal": "^1.3.0", diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 60998ebc61..d47c26095a 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,75 +1,78 @@ import { Stack } from 'expo-router'; +import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export default function AppLayout() { const colors = useThemeColors(); return ( - - - - + - - - - - - - - + > + + + + + + + + + + + + ); } diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts new file mode 100644 index 0000000000..6c1e850f4c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +import { useKiloChatTokenGetter } from './use-kilo-chat-token'; + +/** + * Decodes the `kiloUserId` claim from the kilo-chat JWT and returns it as the + * current user's ID. Returns `null` while loading or if the token cannot be + * decoded. The token is minted by `generateApiToken`, which writes the user id + * as `kiloUserId` (not the standard JWT `sub` claim). + */ +export function useCurrentUserId(): string | null { + const getToken = useKiloChatTokenGetter(); + const [userId, setUserId] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchUserId() { + try { + const token = await getToken(); + if (cancelled) { + return; + } + const parts = token.split('.'); + if (parts.length < 2 || !parts[1]) { + return; + } + const payload = parts[1]; + const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/')); + const parsed = JSON.parse(decoded) as Record; + const kiloUserId = typeof parsed.kiloUserId === 'string' ? parsed.kiloUserId : null; + setUserId(kiloUserId); + } catch { + // Leave userId as null on failure + } + } + + void fetchUserId(); + + return () => { + cancelled = true; + }; + }, [getToken]); + + return userId; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts new file mode 100644 index 0000000000..32e7402aa3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts @@ -0,0 +1,20 @@ +import { type EventServiceClient } from '@kilocode/event-service'; +import { type KiloChatClient } from '@kilocode/kilo-chat'; + +import { useKiloChatContext } from '../kilo-chat-provider'; + +/** + * Returns the {@link KiloChatClient} instance from the nearest + * {@link KiloChatProvider}. Throws if called outside a provider. + */ +export function useKiloChatClient(): KiloChatClient { + return useKiloChatContext().kiloChatClient; +} + +/** + * Returns the {@link EventServiceClient} instance from the nearest + * {@link KiloChatProvider}. Throws if called outside a provider. + */ +export function useEventServiceClient(): EventServiceClient { + return useKiloChatContext().eventService; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts new file mode 100644 index 0000000000..bec3cbaef0 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -0,0 +1,61 @@ +import * as SecureStore from 'expo-secure-store'; +import { useCallback } from 'react'; + +import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; +import { trpcClient } from '@/lib/trpc'; + +type TokenCache = { + authToken: string; + token: string; + expiresAtMs: number; +}; + +// Module-level cache keyed on the user's auth token, so a sign-out followed by +// a different sign-in within the JWT window doesn't return the previous user's +// token. The in-flight ref is keyed the same way for the same reason. +let cache: TokenCache | null = null; +let inFlight: { authToken: string; promise: Promise } | null = null; + +/** + * Returns a stable getter function that fetches a kilo-chat JWT, caching it + * until 60 seconds before expiry. Concurrent callers share a single in-flight + * fetch via a module-level dedup ref. + * + * The auth token is read from SecureStore at call time (matching `trpcClient`) + * rather than captured from `useAuth()`, so a getter constructed before auth + * has loaded — or before the user signs in — picks up the correct token on + * its next call instead of permanently capturing `undefined`. + */ +export function useKiloChatTokenGetter(): () => Promise { + return useCallback(async () => { + const authToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); + if (!authToken) { + throw new Error('Cannot fetch kilo-chat token: not authenticated'); + } + + if (cache && cache.authToken === authToken && cache.expiresAtMs - Date.now() > 60_000) { + return cache.token; + } + + if (inFlight && inFlight.authToken === authToken) { + return inFlight.promise; + } + + const slot = { authToken, promise: fetchAndCacheToken(authToken) }; + inFlight = slot; + try { + return await slot.promise; + } finally { + // Only clear the slot if a concurrent caller hasn't replaced it. + if (inFlight === slot) { + inFlight = null; + } + } + }, []); +} + +async function fetchAndCacheToken(authToken: string): Promise { + const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); + cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() }; + return token; +} diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx new file mode 100644 index 0000000000..f255f96a2b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +import { EventServiceClient } from '@kilocode/event-service'; +import { KiloChatClient } from '@kilocode/kilo-chat'; + +import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; + +import { useKiloChatTokenGetter } from './hooks/use-kilo-chat-token'; + +type KiloChatContextValue = { + eventService: EventServiceClient; + kiloChatClient: KiloChatClient; +}; + +export const KiloChatContext = createContext(null); + +export function useKiloChatContext(): KiloChatContextValue { + const ctx = useContext(KiloChatContext); + if (!ctx) { + throw new Error('useKiloChatContext must be used within a KiloChatProvider'); + } + return ctx; +} + +type KiloChatProviderProps = { + children: React.ReactNode; +}; + +export function KiloChatProvider({ children }: KiloChatProviderProps) { + const getToken = useKiloChatTokenGetter(); + + const [value] = useState(() => { + const eventService = new EventServiceClient({ + url: EVENT_SERVICE_URL, + getToken, + }); + const kiloChatClient = new KiloChatClient({ + eventService, + baseUrl: KILO_CHAT_URL, + getToken, + }); + return { eventService, kiloChatClient }; + }); + + useEffect(() => { + void value.eventService.connect(); + return () => { + value.eventService.disconnect(); + }; + }, [value]); + + return {children}; +} diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index b90c113990..be543127f8 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -18,3 +18,6 @@ export const APPSFLYER_APP_ID: string = required('appsFlyerAppId'); export const CLOUD_AGENT_WS_URL: string = required('cloudAgentWsUrl'); export const SESSION_INGEST_WS_URL: string = required('sessionIngestWsUrl'); + +export const KILO_CHAT_URL: string = required('kiloChatUrl'); +export const EVENT_SERVICE_URL: string = required('eventServiceUrl'); diff --git a/apps/mobile/src/lib/env-keys.js b/apps/mobile/src/lib/env-keys.js index 3ec4d72001..3200f2ecda 100644 --- a/apps/mobile/src/lib/env-keys.js +++ b/apps/mobile/src/lib/env-keys.js @@ -7,4 +7,6 @@ export const ENV_KEYS = { sessionIngestWsUrl: 'SESSION_INGEST_WS_URL', appsFlyerDevKey: 'APPSFLYER_DEV_KEY', appsFlyerAppId: 'APPSFLYER_APP_ID', + kiloChatUrl: 'EXPO_PUBLIC_KILO_CHAT_URL', + eventServiceUrl: 'EXPO_PUBLIC_EVENT_SERVICE_URL', }; diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts index 741140875d..56f794469d 100644 --- a/packages/kilo-chat/src/utils.ts +++ b/packages/kilo-chat/src/utils.ts @@ -18,7 +18,7 @@ export type ConversationCursor = { t: number; c: string }; function base64urlEncode(bytes: Uint8Array): string { let binary = ''; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ea3a33947..3a7e7b96b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,15 @@ importers: '@expo/react-native-action-sheet': specifier: ^4.1.1 version: 4.1.1(@types/react@19.2.14)(react@19.2.0) + '@kilocode/event-service': + specifier: workspace:* + version: link:../../packages/event-service + '@kilocode/kilo-chat': + specifier: workspace:* + version: link:../../packages/kilo-chat + '@kilocode/notifications': + specifier: workspace:* + version: link:../../packages/notifications '@kilocode/trpc': specifier: workspace:* version: link:../../packages/trpc From 9dfe27a56837c5d61f1a9101b419ee36fce52eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:30:17 +0200 Subject: [PATCH 014/289] =?UTF-8?q?feat(mobile):=20PR=205b=20=E2=80=94=20k?= =?UTF-8?q?ilo-chat=20presence,=20events,=20shared=20hooks=20(#2950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. * feat(mobile): add usePresenceSubscription primitive * feat(mobile): subscribe to /presence/app while app is active * feat(mobile): add useInstancePresence hook * feat(mobile): add useConversationPresence hook * fix(mobile): fix lint errors in presence hooks * feat(mobile): add useEventSubscription primitive * feat(mobile): add useInstanceEventSubscription * fix(mobile): apply curly/switch-case-braces lint rules to event hooks * feat(kilo-chat-hooks): create shared package; extract useConversations * feat(kilo-chat-hooks): extract useMessages — base query + optimistic send Move PAGE_SIZE, helper functions (applyReactionAdded/Removed, restoreMessageInCache, removeMessageFromCache, findMessageInCache), useMessages infinite-query hook, and useSendMessage mutation into @kilocode/kilo-chat-hooks. Web's useMessages.ts re-exports the moved hooks and retains local helper copies for remaining mutations (37b will collapse). * feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations * feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared package Moves the live event-stream cache patcher from the web-only useMessages file into @kilocode/kilo-chat-hooks. Adds an optional onActionFailed callback so platform wrappers inject toasts; web passes toast.error. * feat(mobile): wire shared kilo-chat-hooks + platform adapters * fix(kilo-chat-hooks): centralize query keys; tighten event-subscription API - Add packages/kilo-chat-hooks/src/query-keys.ts with conversations/ conversation/messages/bot-status helpers; route every hook + invalidator through it. Fixes the mobile useInstanceEventSubscription bug where invalidations used ['conversations', sandboxId] but the queries register under ['kilo-chat', 'conversations', sandboxId], so list previews and unread counts never refreshed on incoming events. - useEventSubscription now takes a single event name; callers register one hook per event. Drops the events.join('|') dependency hack and the eslint-disable. useInstanceEventSubscription becomes six explicit registrations. - Drop the hardcoded English toast string from useMessageCacheUpdater; onActionFailed is () => void and the message lives at each call site. - Extract useAppActiveAndFocused to deduplicate AppState+focus boilerplate shared by useInstancePresence and useConversationPresence. * fix(mobile): subscribe to conversation.* events on instance context The instance-level subscription was listening for message.created/updated/ deleted, which are published on conversation contexts and never fire here. Replace them with conversation.renamed, conversation.read, and conversation.activity — the events kilo-chat actually pushes to the instance context — so list updates (title, unread, last-activity) invalidate the conversations query as intended. --- apps/mobile/package.json | 1 + apps/mobile/src/app/(app)/_layout.tsx | 136 +++-- .../hooks/use-app-active-and-focused.ts | 33 + .../kilo-chat/hooks/use-app-presence.ts | 21 + .../hooks/use-conversation-presence.ts | 15 + .../kilo-chat/hooks/use-conversations.ts | 11 + .../kilo-chat/hooks/use-event-subscription.ts | 30 + .../hooks/use-instance-event-subscription.ts | 31 + .../kilo-chat/hooks/use-instance-presence.ts | 12 + .../kilo-chat/hooks/use-kilo-chat-client.ts | 21 +- .../kilo-chat/hooks/use-mark-read.ts | 29 + .../kilo-chat/hooks/use-messages.ts | 11 + .../hooks/use-presence-subscription.ts | 16 + .../kilo-chat/kilo-chat-provider.tsx | 11 +- apps/web/package.json | 1 + .../claw/kilo-chat/components/MessageArea.tsx | 4 +- .../claw/kilo-chat/hooks/useBotStatus.ts | 7 +- .../claw/kilo-chat/hooks/useConversations.ts | 135 +--- .../(app)/claw/kilo-chat/hooks/useMessages.ts | 578 +----------------- apps/web/src/contexts/EventServiceContext.tsx | 9 +- packages/kilo-chat-hooks/package.json | 29 + packages/kilo-chat-hooks/src/context.tsx | 26 + packages/kilo-chat-hooks/src/index.ts | 4 + packages/kilo-chat-hooks/src/query-keys.ts | 17 + .../kilo-chat-hooks/src/use-conversations.ts | 126 ++++ packages/kilo-chat-hooks/src/use-messages.ts | 573 +++++++++++++++++ packages/kilo-chat-hooks/tsconfig.json | 20 + pnpm-lock.yaml | 69 ++- 28 files changed, 1160 insertions(+), 816 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-messages.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts create mode 100644 packages/kilo-chat-hooks/package.json create mode 100644 packages/kilo-chat-hooks/src/context.tsx create mode 100644 packages/kilo-chat-hooks/src/index.ts create mode 100644 packages/kilo-chat-hooks/src/query-keys.ts create mode 100644 packages/kilo-chat-hooks/src/use-conversations.ts create mode 100644 packages/kilo-chat-hooks/src/use-messages.ts create mode 100644 packages/kilo-chat-hooks/tsconfig.json diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 75cea06b0a..a59df0c47e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -25,6 +25,7 @@ "@expo/react-native-action-sheet": "^4.1.1", "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", + "@kilocode/kilo-chat-hooks": "workspace:*", "@kilocode/notifications": "workspace:*", "@kilocode/trpc": "workspace:*", "@react-native-community/netinfo": "11.5.2", diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index d47c26095a..97bbe8abd3 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,5 +1,8 @@ +import { type ReactNode } from 'react'; + import { Stack } from 'expo-router'; +import { useAppPresence } from '@/components/kilo-chat/hooks/use-app-presence'; import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -8,71 +11,78 @@ export default function AppLayout() { return ( - - - - - - - - - - - + - + > + + + + + + + + + + + + ); } + +function PresenceMount({ children }: { children: ReactNode }) { + useAppPresence(); + return <>{children}; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts new file mode 100644 index 0000000000..f1066b1b03 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +/** + * True only when the app is in the foreground AND the current expo-router + * route is focused. Used to gate presence subscriptions so we hold them only + * while the user is genuinely on a surface. + */ +export function useAppActiveAndFocused(): boolean { + const [appActive, setAppActive] = useState(AppState.currentState === 'active'); + const [focused, setFocused] = useState(false); + + useEffect(() => { + const sub = AppState.addEventListener('change', state => { + setAppActive(state === 'active'); + }); + return () => { + sub.remove(); + }; + }, []); + + useFocusEffect( + useCallback(() => { + setFocused(true); + return () => { + setFocused(false); + }; + }, []) + ); + + return appActive && focused; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts new file mode 100644 index 0000000000..79389de2a0 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; +import { AppState } from 'react-native'; + +import { presenceContextForPlatform } from '@kilocode/event-service'; + +import { usePresenceSubscription } from './use-presence-subscription'; + +export function useAppPresence() { + const [active, setActive] = useState(AppState.currentState === 'active'); + + useEffect(() => { + const sub = AppState.addEventListener('change', state => { + setActive(state === 'active'); + }); + return () => { + sub.remove(); + }; + }, []); + + usePresenceSubscription(presenceContextForPlatform('app'), active); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts new file mode 100644 index 0000000000..ed2b68dacc --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts @@ -0,0 +1,15 @@ +import { presenceContextForConversation } from '@kilocode/event-service'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; +import { usePresenceSubscription } from './use-presence-subscription'; + +export function useConversationPresence( + sandboxId: string | undefined, + conversationId: string | undefined +) { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) && activeAndFocused + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts new file mode 100644 index 0000000000..221f56e395 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts @@ -0,0 +1,11 @@ +export { + useConversations, + useConversationDetail, + useCreateConversation, + useRenameConversation, + useLeaveConversation, + useMarkConversationRead, + updateConversationPages, + filterConversationPages, +} from '@kilocode/kilo-chat-hooks'; +export type { ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts new file mode 100644 index 0000000000..e3b783bdad --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +/** + * Subscribe to a single event-service event for one context. Call this hook + * once per event name when you need multiple events on the same context. + */ +export function useEventSubscription( + context: string | null, + eventName: string, + onEvent: (payload: unknown) => void +) { + const eventService = useEventServiceClient(); + useEffect(() => { + if (!context) { + return undefined; + } + eventService.subscribe([context]); + const off = eventService.on(eventName, (ctx, payload) => { + if (ctx === context) { + onEvent(payload); + } + }); + return () => { + off(); + eventService.unsubscribe([context]); + }; + }, [eventService, context, eventName, onEvent]); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts new file mode 100644 index 0000000000..8b6f57449e --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { botStatusKey, conversationsKey } from '@kilocode/kilo-chat-hooks'; + +import { useEventSubscription } from './use-event-subscription'; + +export function useInstanceEventSubscription(sandboxId: string | undefined) { + const qc = useQueryClient(); + const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null; + + // conversation.* events are published on the instance context to keep the + // conversation list (last-activity, unread, title, membership) current while + // the user is on the list. message.* events fire on conversation contexts, + // not here. + const invalidateConversations = useCallback(() => { + void qc.invalidateQueries({ queryKey: conversationsKey(sandboxId ?? null) }); + }, [qc, sandboxId]); + + const invalidateBotStatus = useCallback(() => { + void qc.invalidateQueries({ queryKey: botStatusKey(sandboxId ?? null) }); + }, [qc, sandboxId]); + + useEventSubscription(ctx, 'conversation.created', invalidateConversations); + useEventSubscription(ctx, 'conversation.left', invalidateConversations); + useEventSubscription(ctx, 'conversation.renamed', invalidateConversations); + useEventSubscription(ctx, 'conversation.read', invalidateConversations); + useEventSubscription(ctx, 'conversation.activity', invalidateConversations); + useEventSubscription(ctx, 'bot.status', invalidateBotStatus); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts new file mode 100644 index 0000000000..1c04d9eaca --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts @@ -0,0 +1,12 @@ +import { presenceContextForInstance } from '@kilocode/event-service'; + +import { useAppActiveAndFocused } from './use-app-active-and-focused'; +import { usePresenceSubscription } from './use-presence-subscription'; + +export function useInstancePresence(sandboxId: string | undefined) { + const activeAndFocused = useAppActiveAndFocused(); + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : null, + Boolean(sandboxId) && activeAndFocused + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts index 32e7402aa3..cef730a6dc 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts @@ -1,20 +1 @@ -import { type EventServiceClient } from '@kilocode/event-service'; -import { type KiloChatClient } from '@kilocode/kilo-chat'; - -import { useKiloChatContext } from '../kilo-chat-provider'; - -/** - * Returns the {@link KiloChatClient} instance from the nearest - * {@link KiloChatProvider}. Throws if called outside a provider. - */ -export function useKiloChatClient(): KiloChatClient { - return useKiloChatContext().kiloChatClient; -} - -/** - * Returns the {@link EventServiceClient} instance from the nearest - * {@link KiloChatProvider}. Throws if called outside a provider. - */ -export function useEventServiceClient(): EventServiceClient { - return useKiloChatContext().eventService; -} +export { useKiloChatClient, useEventServiceClient } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts new file mode 100644 index 0000000000..4123d96972 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; + +import { badgeBucketForConversation } from '@kilocode/notifications'; + +import { useTRPC } from '@/lib/trpc'; + +export function useMarkRead() { + const trpc = useTRPC(); + const mutation = useMutation( + trpc.user.markChatRead.mutationOptions({ + onSuccess: result => { + if (typeof result.badgeCount === 'number') { + void Notifications.setBadgeCountAsync(result.badgeCount); + } + }, + }) + ); + + return useCallback( + (sandboxId: string, conversationId: string) => { + mutation.mutate({ + badgeBucket: badgeBucketForConversation(sandboxId, conversationId), + }); + }, + [mutation] + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts new file mode 100644 index 0000000000..2f5fb31ec6 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts @@ -0,0 +1,11 @@ +export { + useMessages, + useSendMessage, + useEditMessage, + useDeleteMessage, + useAddReaction, + useRemoveReaction, + useExecuteAction, + useMessageCacheUpdater, +} from '@kilocode/kilo-chat-hooks'; +export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts new file mode 100644 index 0000000000..b3c860bb89 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +export function usePresenceSubscription(context: string | null, active: boolean) { + const eventService = useEventServiceClient(); + useEffect(() => { + if (!active || !context) { + return undefined; + } + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context, active]); +} diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index f255f96a2b..95ed84f647 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { EventServiceClient } from '@kilocode/event-service'; import { KiloChatClient } from '@kilocode/kilo-chat'; +import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks'; import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; @@ -49,5 +50,13 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { }; }, [value]); - return {children}; + return ( + + + {children} + + + ); } diff --git a/apps/web/package.json b/apps/web/package.json index 15a3dd7aad..eb8afda600 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "@kilocode/encryption": "workspace:*", "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", + "@kilocode/kilo-chat-hooks": "workspace:*", "@kilocode/kiloclaw-secret-catalog": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@lottiefiles/dotlottie-react": "^0.17.15", diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index e8d373a230..2789beeb0e 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -127,7 +127,9 @@ export function MessageArea({ conversationId }: MessageAreaProps) { // Bots are excluded inside the hook because their streaming uses // message.created for every token chunk and relies on typing.stopped to // signal stream completion. - useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember); + useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember, () => + toast.error("Couldn't reach the bot — please try again") + ); const sendTyping = useTypingSender(kiloChatClient, conversationId); const markRead = useMarkConversationRead(kiloChatClient); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts index 1ed0553523..c4ca080e6a 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts @@ -3,10 +3,9 @@ import { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { BotStatusRecord, KiloChatEventOf } from '@kilocode/kilo-chat'; +import { botStatusKey } from '@kilocode/kilo-chat-hooks'; import { useKiloChatContext } from '../components/kiloChatContext'; -const botKey = (sandboxId: string) => ['kilo-chat', 'bot-status', sandboxId] as const; - // Matches the bot's old heartbeat cadence so UI staleness thresholds keep // working unchanged. Server-side dedupe absorbs multi-tab / multi-device // polling so this stays at ~1 webhook per sandbox per interval. @@ -20,7 +19,7 @@ export function useBotStatus(): BotStatusRecord | null { if (!sandboxId) return; return kiloChatClient.onBotStatus((_ctx: string, e: KiloChatEventOf<'bot.status'>) => { if (e.sandboxId !== sandboxId) return; - queryClient.setQueryData(botKey(sandboxId), prev => + queryClient.setQueryData(botStatusKey(sandboxId), prev => prev && prev.at >= e.at ? prev : { online: e.online, at: e.at, updatedAt: e.at } ); }); @@ -48,7 +47,7 @@ export function useBotStatus(): BotStatusRecord | null { }, [kiloChatClient, sandboxId]); const { data } = useQuery({ - queryKey: botKey(sandboxId ?? ''), + queryKey: botStatusKey(sandboxId), queryFn: async () => { if (!sandboxId) return null; const res = await kiloChatClient.getBotStatus(sandboxId); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts index faaf06f759..221f56e395 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts @@ -1,124 +1,11 @@ -import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; -import type { InfiniteData } from '@tanstack/react-query'; -import type { KiloChatClient } from '@kilocode/kilo-chat'; -import type { CreateConversationRequest, ConversationListResponse } from '@kilocode/kilo-chat'; - -const CONVERSATIONS_PAGE_SIZE = 50; - -export function useConversations(client: KiloChatClient, sandboxId: string | null) { - return useInfiniteQuery({ - queryKey: ['kilo-chat', 'conversations', sandboxId], - queryFn: ({ pageParam }) => - client.listConversations({ - sandboxId: sandboxId ?? undefined, - limit: CONVERSATIONS_PAGE_SIZE, - cursor: pageParam ?? undefined, - }), - initialPageParam: null as string | null, - getNextPageParam: lastPage => lastPage.nextCursor, - enabled: !!sandboxId, - select: data => ({ - ...data, - conversations: data.pages.flatMap(p => p.conversations), - }), - }); -} - -export function useConversationDetail(client: KiloChatClient, conversationId: string | null) { - return useQuery({ - queryKey: ['kilo-chat', 'conversation', conversationId], - queryFn: () => client.getConversation(conversationId ?? ''), - enabled: !!conversationId, - }); -} - -export function useCreateConversation(client: KiloChatClient) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (req: CreateConversationRequest) => client.createConversation(req), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); - }, - }); -} - -export function useRenameConversation(client: KiloChatClient) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ conversationId, title }: { conversationId: string; title: string }) => - client.renameConversation(conversationId, { title }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); - }, - }); -} - -export function useLeaveConversation(client: KiloChatClient) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (conversationId: string) => client.leaveConversation(conversationId), - onSuccess: (_data, conversationId) => { - queryClient.removeQueries({ queryKey: ['kilo-chat', 'conversation', conversationId] }); - queryClient.removeQueries({ queryKey: ['kilo-chat', 'messages', conversationId] }); - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); - }, - }); -} - -export type ConversationListInfiniteData = InfiniteData; - -export function updateConversationPages( - data: ConversationListInfiniteData | undefined, - mapItem: ( - c: ConversationListResponse['conversations'][number] - ) => ConversationListResponse['conversations'][number] -): ConversationListInfiniteData | undefined { - if (!data) return data; - return { - ...data, - pages: data.pages.map(page => ({ - ...page, - conversations: page.conversations.map(mapItem), - })), - }; -} - -export function filterConversationPages( - data: ConversationListInfiniteData | undefined, - predicate: (c: ConversationListResponse['conversations'][number]) => boolean -): ConversationListInfiniteData | undefined { - if (!data) return data; - return { - ...data, - pages: data.pages.map(page => ({ - ...page, - conversations: page.conversations.filter(predicate), - })), - }; -} - -export function useMarkConversationRead(client: KiloChatClient) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (conversationId: string) => client.markConversationRead(conversationId), - onMutate: conversationId => { - // Optimistically set lastReadAt = now in all cached conversation lists - const now = Date.now(); - const queryKey = ['kilo-chat', 'conversations']; - const previous = queryClient.getQueriesData({ queryKey }); - queryClient.setQueriesData({ queryKey }, old => - updateConversationPages(old, c => - c.conversationId === conversationId ? { ...c, lastReadAt: now } : c - ) - ); - return { previous }; - }, - onError: (_err, _variables, context) => { - if (context?.previous) { - for (const [key, data] of context.previous) { - queryClient.setQueryData(key, data); - } - } - }, - }); -} +export { + useConversations, + useConversationDetail, + useCreateConversation, + useRenameConversation, + useLeaveConversation, + useMarkConversationRead, + updateConversationPages, + filterConversationPages, +} from '@kilocode/kilo-chat-hooks'; +export type { ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts index de70a427c5..2f5fb31ec6 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts @@ -1,567 +1,11 @@ -import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; -import type { InfiniteData } from '@tanstack/react-query'; -import type { KiloChatClient } from '@kilocode/kilo-chat'; -import type { - Message, - ReactionSummary, - CreateMessageRequest, - EditMessageRequest, - MessageCreatedEvent, - MessageUpdatedEvent, - MessageDeletedEvent, - MessageDeliveryFailedEvent, - ActionDeliveryFailedEvent, - ReactionAddedEvent, - ReactionRemovedEvent, - ExecApprovalDecision, -} from '@kilocode/kilo-chat'; -import { useEffect } from 'react'; -import { kiloclawConversationContext } from '@kilocode/event-service'; -import { toast } from 'sonner'; - -const PAGE_SIZE = 50; - -function applyReactionAdded( - reactions: ReactionSummary[], - emoji: string, - memberId: string -): ReactionSummary[] { - const existing = reactions.find(r => r.emoji === emoji); - if (existing) { - if (existing.memberIds.includes(memberId)) return reactions; - return reactions.map(r => - r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r - ); - } - return [...reactions, { emoji, count: 1, memberIds: [memberId] }]; -} - -function applyReactionRemoved( - reactions: ReactionSummary[], - emoji: string, - memberId: string -): ReactionSummary[] { - return reactions - .map(r => { - if (r.emoji !== emoji) return r; - const memberIds = r.memberIds.filter(id => id !== memberId); - return { ...r, count: memberIds.length, memberIds }; - }) - .filter(r => r.count > 0); -} - -/** - * Splice a snapshotted message back into the current cache state. If the - * message no longer exists in any page (e.g. a concurrent delete event), the - * cache is left unchanged so we do not resurrect it. - */ -function restoreMessageInCache( - queryClient: ReturnType, - queryKey: readonly unknown[], - snapshot: Message -): void { - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - let replaced = false; - const pages = old.pages.map(page => - page.map(msg => { - if (msg.id !== snapshot.id) return msg; - replaced = true; - return snapshot; - }) - ); - if (!replaced) return old; - return { ...old, pages }; - }); -} - -/** - * Remove a message by id from the current cache state. Used to roll back the - * optimistic insert performed by `useSendMessage` without touching any other - * concurrently-optimistic messages. - */ -function removeMessageFromCache( - queryClient: ReturnType, - queryKey: readonly unknown[], - messageId: string -): void { - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => page.filter(msg => msg.id !== messageId)), - }; - }); -} - -function findMessageInCache( - queryClient: ReturnType, - queryKey: readonly unknown[], - messageId: string -): Message | undefined { - const data = queryClient.getQueryData>(queryKey); - if (!data) return undefined; - for (const page of data.pages) { - const match = page.find(msg => msg.id === messageId); - if (match) return match; - } - return undefined; -} - -export function useMessages(client: KiloChatClient, conversationId: string | null) { - return useInfiniteQuery({ - queryKey: ['kilo-chat', 'messages', conversationId], - queryFn: async ({ pageParam }) => { - return client.listMessages(conversationId ?? '', { before: pageParam, limit: PAGE_SIZE }); - }, - initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => { - if (lastPage.length < PAGE_SIZE) return undefined; - return lastPage[lastPage.length - 1]?.id; - }, - enabled: !!conversationId, - select: data => ({ - ...data, - messages: data.pages.flatMap(p => p).reverse(), - }), - }); -} - -export type SendMessageVariables = CreateMessageRequest & { clientId: string }; - -export function useSendMessage( - client: KiloChatClient, - conversationId: string | null, - currentUserId: string -) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (req: SendMessageVariables) => client.sendMessage(req), - onMutate: async (variables: SendMessageVariables) => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const pendingId = `pending-${variables.clientId}`; - const optimisticMessage: Message = { - id: pendingId, - senderId: currentUserId, - content: variables.content, - inReplyToMessageId: variables.inReplyToMessageId ?? null, - updatedAt: null, - clientUpdatedAt: null, - deleted: false, - deliveryFailed: false, - reactions: [], - }; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - const firstPage = old.pages[0] ?? []; - return { ...old, pages: [[optimisticMessage, ...firstPage], ...old.pages.slice(1)] }; - }); - return { queryKey, pendingId }; - }, - onSuccess: (response, _variables, context) => { - if (!context) return; - const { queryKey, pendingId } = context; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === pendingId ? { ...msg, id: response.messageId } : msg)) - ), - }; - }); - }, - onError: (_err, _variables, context) => { - if (!context) return; - removeMessageFromCache(queryClient, context.queryKey, context.pendingId); - }, - }); -} - -export function useEditMessage(client: KiloChatClient, conversationId: string | null) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ messageId, ...req }: EditMessageRequest & { messageId: string }) => - client.editMessage(messageId, req), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id === variables.messageId - ? { ...msg, content: variables.content, clientUpdatedAt: variables.timestamp } - : msg - ) - ), - }; - }); - return { queryKey, snapshot }; - }, - onError: (_err, _variables, context) => { - if (!context?.snapshot) return; - restoreMessageInCache(queryClient, context.queryKey, context.snapshot); - }, - }); -} - -export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ messageId, conversationId }: { messageId: string; conversationId: string }) => - client.deleteMessage(messageId, { conversationId }), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === variables.messageId ? { ...msg, deleted: true } : msg)) - ), - }; - }); - return { queryKey, snapshot }; - }, - onError: (_err, _variables, context) => { - if (!context?.snapshot) return; - restoreMessageInCache(queryClient, context.queryKey, context.snapshot); - }, - }); -} - -export function useAddReaction( - client: KiloChatClient, - conversationId: string | null, - currentUserId: string -) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => - client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== variables.messageId - ? msg - : { - ...msg, - reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId), - } - ) - ), - }; - }); - return { queryKey, snapshot }; - }, - onError: (_err, _variables, context) => { - if (!context?.snapshot) return; - restoreMessageInCache(queryClient, context.queryKey, context.snapshot); - }, - }); -} - -export function useRemoveReaction( - client: KiloChatClient, - conversationId: string | null, - currentUserId: string -) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => - client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== variables.messageId - ? msg - : { - ...msg, - reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId), - } - ) - ), - }; - }); - return { queryKey, snapshot }; - }, - onError: (_err, _variables, context) => { - if (!context?.snapshot) return; - restoreMessageInCache(queryClient, context.queryKey, context.snapshot); - }, - }); -} - -export function useExecuteAction( - client: KiloChatClient, - conversationId: string | null, - currentUserId: string -) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - messageId, - groupId, - value, - }: { - messageId: string; - groupId: string; - value: ExecApprovalDecision; - }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }), - onMutate: async variables => { - if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - await queryClient.cancelQueries({ queryKey }); - const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); - // Optimistically mark the action as resolved - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => { - if (msg.id !== variables.messageId) return msg; - return { - ...msg, - content: msg.content.map(block => { - if (block.type !== 'actions') return block; - if (block.groupId !== variables.groupId) return block; - return { - ...block, - resolved: { - value: variables.value, - resolvedBy: currentUserId, - resolvedAt: Date.now(), - }, - }; - }), - }; - }) - ), - }; - }); - return { queryKey, snapshot }; - }, - onError: (_err, _variables, context) => { - if (!context?.snapshot) return; - restoreMessageInCache(queryClient, context.queryKey, context.snapshot); - }, - }); -} - -/** - * Subscribes to real-time kilo-chat events on the shared client and applies - * them to the React Query message cache for the active conversation. - * - * Each subscription receives the fully validated typed payload from the - * client (Zod-checked inside `KiloChatClient.on`), so no casts are needed. - * - * Event Service delivers every subscribed context to every handler, so we - * also validate `ctx` against the expected conversation context before - * mutating the cache. This protects against stale subscriptions, context - * leaks, or server-side routing drift. - */ -export function useMessageCacheUpdater( - client: KiloChatClient, - sandboxId: string | null, - conversationId: string | null, - // Called with the event context and sender id when a human sender's - // message lands. Bots stream tokens through message.created events and - // end their own typing state via explicit typing.stopped, so we must not - // clear on bot messages or the indicator disappears mid-stream. - onHumanMessageCreated?: (ctx: string, senderId: string) => void -): void { - const queryClient = useQueryClient(); - - useEffect(() => { - if (!conversationId || !sandboxId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; - const expectedContext = kiloclawConversationContext(sandboxId, conversationId); - - const onCreated = (ctx: string, e: MessageCreatedEvent) => { - if (ctx !== expectedContext) return; - if (!e.senderId.startsWith('bot:')) { - onHumanMessageCreated?.(ctx, e.senderId); - } - const newMessage: Message = { - id: e.messageId, - senderId: e.senderId, - content: e.content, - inReplyToMessageId: e.inReplyToMessageId, - updatedAt: null, - clientUpdatedAt: null, - deleted: false, - deliveryFailed: false, - reactions: [], - }; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - // Skip if this messageId already exists - for (const page of old.pages) { - if (page.some(msg => msg.id === e.messageId)) return old; - } - // Replace the matching pending optimistic message if clientId correlates - if (e.clientId) { - const pendingId = `pending-${e.clientId}`; - for (const page of old.pages) { - if (page.some(msg => msg.id === pendingId)) { - return { - ...old, - pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))), - }; - } - } - } - const firstPage = old.pages[0] ?? []; - return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] }; - }); - }; - - const onUpdated = (ctx: string, e: MessageUpdatedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id === e.messageId - ? { - ...msg, - content: e.content, - clientUpdatedAt: e.clientUpdatedAt, - } - : msg - ) - ), - }; - }); - }; - - const onDeleted = (ctx: string, e: MessageDeletedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === e.messageId ? { ...msg, deleted: true } : msg)) - ), - }; - }); - }; - - const onDeliveryFailed = (ctx: string, e: MessageDeliveryFailedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === e.messageId ? { ...msg, deliveryFailed: true } : msg)) - ), - }; - }); - }; - - const onActionFailed = (ctx: string, e: ActionDeliveryFailedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => { - if (msg.id !== e.messageId) return msg; - return { - ...msg, - content: msg.content.map(block => { - if (block.type !== 'actions') return block; - if (block.groupId !== e.groupId) return block; - return { ...block, resolved: undefined }; - }), - }; - }) - ), - }; - }); - toast.error("Couldn't reach the bot — please try again"); - }; - - const onReactionAdded = (ctx: string, e: ReactionAddedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== e.messageId - ? msg - : { ...msg, reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId) } - ) - ), - }; - }); - }; - - const onReactionRemoved = (ctx: string, e: ReactionRemovedEvent) => { - if (ctx !== expectedContext) return; - queryClient.setQueryData>(queryKey, old => { - if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== e.messageId - ? msg - : { - ...msg, - reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId), - } - ) - ), - }; - }); - }; - - const offs = [ - client.onMessageCreated(onCreated), - client.onMessageUpdated(onUpdated), - client.onMessageDeleted(onDeleted), - client.onMessageDeliveryFailed(onDeliveryFailed), - client.onActionDeliveryFailed(onActionFailed), - client.onReactionAdded(onReactionAdded), - client.onReactionRemoved(onReactionRemoved), - ]; - return () => { - for (const off of offs) off(); - }; - }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated]); -} +export { + useMessages, + useSendMessage, + useEditMessage, + useDeleteMessage, + useAddReaction, + useRemoveReaction, + useExecuteAction, + useMessageCacheUpdater, +} from '@kilocode/kilo-chat-hooks'; +export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx index 03bc458faa..31b0318c6c 100644 --- a/apps/web/src/contexts/EventServiceContext.tsx +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react'; import { EventServiceClient } from '@kilocode/event-service'; import { KiloChatClient } from '@kilocode/kilo-chat'; +import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks'; import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/constants'; import { getKiloChatToken, clearKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; @@ -60,7 +61,13 @@ export function EventServiceProvider({ children }: EventServiceProviderProps) { [eventService, kiloChatClient] ); - return {children}; + return ( + + + {children} + + + ); } export function useEventServiceClient(): EventServiceContextValue { diff --git a/packages/kilo-chat-hooks/package.json b/packages/kilo-chat-hooks/package.json new file mode 100644 index 0000000000..9d61da112d --- /dev/null +++ b/packages/kilo-chat-hooks/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kilocode/kilo-chat-hooks", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "peerDependencies": { + "react": "*", + "@tanstack/react-query": "*" + }, + "dependencies": { + "@kilocode/kilo-chat": "workspace:*", + "@kilocode/event-service": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "react": "^19.2.4", + "@tanstack/react-query": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kilo-chat-hooks/src/context.tsx b/packages/kilo-chat-hooks/src/context.tsx new file mode 100644 index 0000000000..55c017fbd7 --- /dev/null +++ b/packages/kilo-chat-hooks/src/context.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { KiloChatClient } from '@kilocode/kilo-chat'; +import type { EventServiceClient } from '@kilocode/event-service'; + +type Value = { + kiloChatClient: KiloChatClient; + eventService: EventServiceClient; +}; + +const Ctx = createContext(null); + +export function KiloChatHooksProvider(props: { value: Value; children: ReactNode }) { + return {props.children}; +} + +export function useKiloChatClient(): KiloChatClient { + const v = useContext(Ctx); + if (!v) throw new Error('useKiloChatClient: missing KiloChatHooksProvider'); + return v.kiloChatClient; +} + +export function useEventServiceClient(): EventServiceClient { + const v = useContext(Ctx); + if (!v) throw new Error('useEventServiceClient: missing KiloChatHooksProvider'); + return v.eventService; +} diff --git a/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts new file mode 100644 index 0000000000..599b343147 --- /dev/null +++ b/packages/kilo-chat-hooks/src/index.ts @@ -0,0 +1,4 @@ +export * from './context'; +export * from './query-keys'; +export * from './use-conversations'; +export * from './use-messages'; diff --git a/packages/kilo-chat-hooks/src/query-keys.ts b/packages/kilo-chat-hooks/src/query-keys.ts new file mode 100644 index 0000000000..08a7c24b13 --- /dev/null +++ b/packages/kilo-chat-hooks/src/query-keys.ts @@ -0,0 +1,17 @@ +// Shared React Query key builders so subscribers (event handlers, mutations) +// invalidate exactly the keys the queries register under. Drift here silently +// breaks live updates — keep all kilo-chat keys in this file. + +export const conversationsKey = (sandboxId: string | null) => + ['kilo-chat', 'conversations', sandboxId] as const; + +export const conversationsKeyAll = () => ['kilo-chat', 'conversations'] as const; + +export const conversationKey = (conversationId: string | null) => + ['kilo-chat', 'conversation', conversationId] as const; + +export const messagesKey = (conversationId: string | null) => + ['kilo-chat', 'messages', conversationId] as const; + +export const botStatusKey = (sandboxId: string | null) => + ['kilo-chat', 'bot-status', sandboxId] as const; diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts new file mode 100644 index 0000000000..430e797fc0 --- /dev/null +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -0,0 +1,126 @@ +import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import type { KiloChatClient } from '@kilocode/kilo-chat'; +import type { CreateConversationRequest, ConversationListResponse } from '@kilocode/kilo-chat'; + +import { conversationKey, conversationsKey, conversationsKeyAll, messagesKey } from './query-keys'; + +const CONVERSATIONS_PAGE_SIZE = 50; + +export function useConversations(client: KiloChatClient, sandboxId: string | null) { + return useInfiniteQuery({ + queryKey: conversationsKey(sandboxId), + queryFn: ({ pageParam }) => + client.listConversations({ + sandboxId: sandboxId ?? undefined, + limit: CONVERSATIONS_PAGE_SIZE, + cursor: pageParam ?? undefined, + }), + initialPageParam: null as string | null, + getNextPageParam: lastPage => lastPage.nextCursor, + enabled: !!sandboxId, + select: data => ({ + ...data, + conversations: data.pages.flatMap(p => p.conversations), + }), + }); +} + +export function useConversationDetail(client: KiloChatClient, conversationId: string | null) { + return useQuery({ + queryKey: conversationKey(conversationId), + queryFn: () => client.getConversation(conversationId ?? ''), + enabled: !!conversationId, + }); +} + +export function useCreateConversation(client: KiloChatClient) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateConversationRequest) => client.createConversation(req), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); + }, + }); +} + +export function useRenameConversation(client: KiloChatClient) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ conversationId, title }: { conversationId: string; title: string }) => + client.renameConversation(conversationId, { title }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); + }, + }); +} + +export function useLeaveConversation(client: KiloChatClient) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (conversationId: string) => client.leaveConversation(conversationId), + onSuccess: (_data, conversationId) => { + queryClient.removeQueries({ queryKey: conversationKey(conversationId) }); + queryClient.removeQueries({ queryKey: messagesKey(conversationId) }); + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); + }, + }); +} + +export type ConversationListInfiniteData = InfiniteData; + +export function updateConversationPages( + data: ConversationListInfiniteData | undefined, + mapItem: ( + c: ConversationListResponse['conversations'][number] + ) => ConversationListResponse['conversations'][number] +): ConversationListInfiniteData | undefined { + if (!data) return data; + return { + ...data, + pages: data.pages.map(page => ({ + ...page, + conversations: page.conversations.map(mapItem), + })), + }; +} + +export function filterConversationPages( + data: ConversationListInfiniteData | undefined, + predicate: (c: ConversationListResponse['conversations'][number]) => boolean +): ConversationListInfiniteData | undefined { + if (!data) return data; + return { + ...data, + pages: data.pages.map(page => ({ + ...page, + conversations: page.conversations.filter(predicate), + })), + }; +} + +export function useMarkConversationRead(client: KiloChatClient) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (conversationId: string) => client.markConversationRead(conversationId), + onMutate: conversationId => { + // Optimistically set lastReadAt = now in all cached conversation lists + const now = Date.now(); + const queryKey = conversationsKeyAll(); + const previous = queryClient.getQueriesData({ queryKey }); + queryClient.setQueriesData({ queryKey }, old => + updateConversationPages(old, c => + c.conversationId === conversationId ? { ...c, lastReadAt: now } : c + ) + ); + return { previous }; + }, + onError: (_err, _variables, context) => { + if (context?.previous) { + for (const [key, data] of context.previous) { + queryClient.setQueryData(key, data); + } + } + }, + }); +} diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts new file mode 100644 index 0000000000..b2302e1903 --- /dev/null +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -0,0 +1,573 @@ +import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import type { KiloChatClient } from '@kilocode/kilo-chat'; +import type { + Message, + ReactionSummary, + CreateMessageRequest, + EditMessageRequest, + ExecApprovalDecision, + MessageCreatedEvent, + MessageUpdatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + ActionDeliveryFailedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, +} from '@kilocode/kilo-chat'; +import { useEffect } from 'react'; +import { kiloclawConversationContext } from '@kilocode/event-service'; + +import { messagesKey } from './query-keys'; + +export const PAGE_SIZE = 50; + +export function applyReactionAdded( + reactions: ReactionSummary[], + emoji: string, + memberId: string +): ReactionSummary[] { + const existing = reactions.find(r => r.emoji === emoji); + if (existing) { + if (existing.memberIds.includes(memberId)) return reactions; + return reactions.map(r => + r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r + ); + } + return [...reactions, { emoji, count: 1, memberIds: [memberId] }]; +} + +export function applyReactionRemoved( + reactions: ReactionSummary[], + emoji: string, + memberId: string +): ReactionSummary[] { + return reactions + .map(r => { + if (r.emoji !== emoji) return r; + const memberIds = r.memberIds.filter(id => id !== memberId); + return { ...r, count: memberIds.length, memberIds }; + }) + .filter(r => r.count > 0); +} + +/** + * Splice a snapshotted message back into the current cache state. If the + * message no longer exists in any page (e.g. a concurrent delete event), the + * cache is left unchanged so we do not resurrect it. + */ +export function restoreMessageInCache( + queryClient: ReturnType, + queryKey: readonly unknown[], + snapshot: Message +): void { + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + let replaced = false; + const pages = old.pages.map(page => + page.map(msg => { + if (msg.id !== snapshot.id) return msg; + replaced = true; + return snapshot; + }) + ); + if (!replaced) return old; + return { ...old, pages }; + }); +} + +/** + * Remove a message by id from the current cache state. Used to roll back the + * optimistic insert performed by `useSendMessage` without touching any other + * concurrently-optimistic messages. + */ +export function removeMessageFromCache( + queryClient: ReturnType, + queryKey: readonly unknown[], + messageId: string +): void { + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => page.filter(msg => msg.id !== messageId)), + }; + }); +} + +export function findMessageInCache( + queryClient: ReturnType, + queryKey: readonly unknown[], + messageId: string +): Message | undefined { + const data = queryClient.getQueryData>(queryKey); + if (!data) return undefined; + for (const page of data.pages) { + const match = page.find(msg => msg.id === messageId); + if (match) return match; + } + return undefined; +} + +export function useMessages(client: KiloChatClient, conversationId: string | null) { + return useInfiniteQuery({ + queryKey: messagesKey(conversationId), + queryFn: async ({ pageParam }) => { + return client.listMessages(conversationId ?? '', { before: pageParam, limit: PAGE_SIZE }); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => { + if (lastPage.length < PAGE_SIZE) return undefined; + return lastPage[lastPage.length - 1]?.id; + }, + enabled: !!conversationId, + select: data => ({ + ...data, + messages: data.pages.flatMap(p => p).reverse(), + }), + }); +} + +export type SendMessageVariables = CreateMessageRequest & { clientId: string }; + +export function useSendMessage( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (req: SendMessageVariables) => client.sendMessage(req), + onMutate: async (variables: SendMessageVariables) => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const pendingId = `pending-${variables.clientId}`; + const optimisticMessage: Message = { + id: pendingId, + senderId: currentUserId, + content: variables.content, + inReplyToMessageId: variables.inReplyToMessageId ?? null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + const firstPage = old.pages[0] ?? []; + return { ...old, pages: [[optimisticMessage, ...firstPage], ...old.pages.slice(1)] }; + }); + return { queryKey, pendingId }; + }, + onSuccess: (response, _variables, context) => { + if (!context) return; + const { queryKey, pendingId } = context; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => (msg.id === pendingId ? { ...msg, id: response.messageId } : msg)) + ), + }; + }); + }, + onError: (_err, _variables, context) => { + if (!context) return; + removeMessageFromCache(queryClient, context.queryKey, context.pendingId); + }, + }); +} + +export function useEditMessage(client: KiloChatClient, conversationId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ messageId, ...req }: EditMessageRequest & { messageId: string }) => + client.editMessage(messageId, req), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id === variables.messageId + ? { ...msg, content: variables.content, clientUpdatedAt: variables.timestamp } + : msg + ) + ), + }; + }); + return { queryKey, snapshot }; + }, + onError: (_err, _variables, context) => { + if (!context?.snapshot) return; + restoreMessageInCache(queryClient, context.queryKey, context.snapshot); + }, + }); +} + +export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ messageId, conversationId }: { messageId: string; conversationId: string }) => + client.deleteMessage(messageId, { conversationId }), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => (msg.id === variables.messageId ? { ...msg, deleted: true } : msg)) + ), + }; + }); + return { queryKey, snapshot }; + }, + onError: (_err, _variables, context) => { + if (!context?.snapshot) return; + restoreMessageInCache(queryClient, context.queryKey, context.snapshot); + }, + }); +} + +export function useAddReaction( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => + client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id !== variables.messageId + ? msg + : { + ...msg, + reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId), + } + ) + ), + }; + }); + return { queryKey, snapshot }; + }, + onError: (_err, _variables, context) => { + if (!context?.snapshot) return; + restoreMessageInCache(queryClient, context.queryKey, context.snapshot); + }, + }); +} + +export function useRemoveReaction( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => + client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id !== variables.messageId + ? msg + : { + ...msg, + reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId), + } + ) + ), + }; + }); + return { queryKey, snapshot }; + }, + onError: (_err, _variables, context) => { + if (!context?.snapshot) return; + restoreMessageInCache(queryClient, context.queryKey, context.snapshot); + }, + }); +} + +export function useExecuteAction( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + messageId, + groupId, + value, + }: { + messageId: string; + groupId: string; + value: ExecApprovalDecision; + }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }), + onMutate: async variables => { + if (!conversationId) return; + const queryKey = messagesKey(conversationId); + await queryClient.cancelQueries({ queryKey }); + const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); + // Optimistically mark the action as resolved + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => { + if (msg.id !== variables.messageId) return msg; + return { + ...msg, + content: msg.content.map(block => { + if (block.type !== 'actions') return block; + if (block.groupId !== variables.groupId) return block; + return { + ...block, + resolved: { + value: variables.value, + resolvedBy: currentUserId, + resolvedAt: Date.now(), + }, + }; + }), + }; + }) + ), + }; + }); + return { queryKey, snapshot }; + }, + onError: (_err, _variables, context) => { + if (!context?.snapshot) return; + restoreMessageInCache(queryClient, context.queryKey, context.snapshot); + }, + }); +} + +/** + * Subscribes to real-time kilo-chat events on the shared client and applies + * them to the React Query message cache for the active conversation. + * + * Each subscription receives the fully validated typed payload from the + * client (Zod-checked inside `KiloChatClient.on`), so no casts are needed. + * + * Event Service delivers every subscribed context to every handler, so we + * also validate `ctx` against the expected conversation context before + * mutating the cache. This protects against stale subscriptions, context + * leaks, or server-side routing drift. + */ +export function useMessageCacheUpdater( + client: KiloChatClient, + sandboxId: string | null, + conversationId: string | null, + // Called with the event context and sender id when a human sender's + // message lands. Bots stream tokens through message.created events and + // end their own typing state via explicit typing.stopped, so we must not + // clear on bot messages or the indicator disappears mid-stream. + onHumanMessageCreated?: (ctx: string, senderId: string) => void, + // Fires when the server reports an action.delivery_failed for a message in + // this conversation, after the optimistic resolved-state has been rolled + // back. The shared package is platform-agnostic, so the user-visible + // message lives at the call site (web: sonner toast; mobile: native toast). + onActionFailed?: () => void +): void { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!conversationId || !sandboxId) return; + const queryKey = messagesKey(conversationId); + const expectedContext = kiloclawConversationContext(sandboxId, conversationId); + + const onCreated = (ctx: string, e: MessageCreatedEvent) => { + if (ctx !== expectedContext) return; + if (!e.senderId.startsWith('bot:')) { + onHumanMessageCreated?.(ctx, e.senderId); + } + const newMessage: Message = { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + // Skip if this messageId already exists + for (const page of old.pages) { + if (page.some(msg => msg.id === e.messageId)) return old; + } + // Replace the matching pending optimistic message if clientId correlates + if (e.clientId) { + const pendingId = `pending-${e.clientId}`; + for (const page of old.pages) { + if (page.some(msg => msg.id === pendingId)) { + return { + ...old, + pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))), + }; + } + } + } + const firstPage = old.pages[0] ?? []; + return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] }; + }); + }; + + const onUpdated = (ctx: string, e: MessageUpdatedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id === e.messageId + ? { + ...msg, + content: e.content, + clientUpdatedAt: e.clientUpdatedAt, + } + : msg + ) + ), + }; + }); + }; + + const onDeleted = (ctx: string, e: MessageDeletedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => (msg.id === e.messageId ? { ...msg, deleted: true } : msg)) + ), + }; + }); + }; + + const onDeliveryFailed = (ctx: string, e: MessageDeliveryFailedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => (msg.id === e.messageId ? { ...msg, deliveryFailed: true } : msg)) + ), + }; + }); + }; + + const onActionDeliveryFailed = (ctx: string, e: ActionDeliveryFailedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => { + if (msg.id !== e.messageId) return msg; + return { + ...msg, + content: msg.content.map(block => { + if (block.type !== 'actions') return block; + if (block.groupId !== e.groupId) return block; + return { ...block, resolved: undefined }; + }), + }; + }) + ), + }; + }); + onActionFailed?.(); + }; + + const onReactionAdded = (ctx: string, e: ReactionAddedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id !== e.messageId + ? msg + : { ...msg, reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId) } + ) + ), + }; + }); + }; + + const onReactionRemoved = (ctx: string, e: ReactionRemovedEvent) => { + if (ctx !== expectedContext) return; + queryClient.setQueryData>(queryKey, old => { + if (!old) return old; + return { + ...old, + pages: old.pages.map(page => + page.map(msg => + msg.id !== e.messageId + ? msg + : { + ...msg, + reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId), + } + ) + ), + }; + }); + }; + + const offs = [ + client.onMessageCreated(onCreated), + client.onMessageUpdated(onUpdated), + client.onMessageDeleted(onDeleted), + client.onMessageDeliveryFailed(onDeliveryFailed), + client.onActionDeliveryFailed(onActionDeliveryFailed), + client.onReactionAdded(onReactionAdded), + client.onReactionRemoved(onReactionRemoved), + ]; + return () => { + for (const off of offs) off(); + }; + }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated, onActionFailed]); +} diff --git a/packages/kilo-chat-hooks/tsconfig.json b/packages/kilo-chat-hooks/tsconfig.json new file mode 100644 index 0000000000..e480ae3539 --- /dev/null +++ b/packages/kilo-chat-hooks/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["dom", "dom.iterable", "esnext"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a7e7b96b6..b252a8a276 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@kilocode/kilo-chat': specifier: workspace:* version: link:../../packages/kilo-chat + '@kilocode/kilo-chat-hooks': + specifier: workspace:* + version: link:../../packages/kilo-chat-hooks '@kilocode/notifications': specifier: workspace:* version: link:../../packages/notifications @@ -514,6 +517,9 @@ importers: '@kilocode/kilo-chat': specifier: workspace:* version: link:../../packages/kilo-chat + '@kilocode/kilo-chat-hooks': + specifier: workspace:* + version: link:../../packages/kilo-chat-hooks '@kilocode/kiloclaw-secret-catalog': specifier: workspace:* version: link:../../packages/kiloclaw-secret-catalog @@ -1005,6 +1011,31 @@ importers: specifier: ~3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/kilo-chat-hooks: + dependencies: + '@kilocode/event-service': + specifier: workspace:* + version: link:../event-service + '@kilocode/kilo-chat': + specifier: workspace:* + version: link:../kilo-chat + devDependencies: + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.90.21(react@19.2.4) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@typescript/native-preview': + specifier: 'catalog:' + version: 7.0.0-dev.20260319.1 + react: + specifier: ^19.2.4 + version: 19.2.4 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/kiloclaw-secret-catalog: dependencies: zod: @@ -1570,7 +1601,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -16759,7 +16790,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.4 miniflare: 4.20260310.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: 4.72.0(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -22267,7 +22298,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -25698,25 +25729,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -26368,19 +26380,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jimp-compact@0.16.1: {} jiti@2.6.1: {} From 4264d7e79f2ccfe7ed60521f0c507c61738466d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:35:51 +0200 Subject: [PATCH 015/289] =?UTF-8?q?feat(mobile):=20PR=205c=20=E2=80=94=20k?= =?UTF-8?q?ilo-chat=20UI=20components=20(#2951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. * feat(mobile): add usePresenceSubscription primitive * feat(mobile): subscribe to /presence/app while app is active * feat(mobile): add useInstancePresence hook * feat(mobile): add useConversationPresence hook * fix(mobile): fix lint errors in presence hooks * feat(mobile): add useEventSubscription primitive * feat(mobile): add useInstanceEventSubscription * fix(mobile): apply curly/switch-case-braces lint rules to event hooks * feat(kilo-chat-hooks): create shared package; extract useConversations * feat(kilo-chat-hooks): extract useMessages — base query + optimistic send Move PAGE_SIZE, helper functions (applyReactionAdded/Removed, restoreMessageInCache, removeMessageFromCache, findMessageInCache), useMessages infinite-query hook, and useSendMessage mutation into @kilocode/kilo-chat-hooks. Web's useMessages.ts re-exports the moved hooks and retains local helper copies for remaining mutations (37b will collapse). * feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations * feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared package Moves the live event-stream cache patcher from the web-only useMessages file into @kilocode/kilo-chat-hooks. Adds an optional onActionFailed callback so platform wrappers inject toasts; web passes toast.error. * feat(mobile): wire shared kilo-chat-hooks + platform adapters * fix(kilo-chat-hooks): centralize query keys; tighten event-subscription API - Add packages/kilo-chat-hooks/src/query-keys.ts with conversations/ conversation/messages/bot-status helpers; route every hook + invalidator through it. Fixes the mobile useInstanceEventSubscription bug where invalidations used ['conversations', sandboxId] but the queries register under ['kilo-chat', 'conversations', sandboxId], so list previews and unread counts never refreshed on incoming events. - useEventSubscription now takes a single event name; callers register one hook per event. Drops the events.join('|') dependency hack and the eslint-disable. useInstanceEventSubscription becomes six explicit registrations. - Drop the hardcoded English toast string from useMessageCacheUpdater; onActionFailed is () => void and the message lives at each call site. - Extract useAppActiveAndFocused to deduplicate AppState+focus boilerplate shared by useInstancePresence and useConversationPresence. * fix(mobile): subscribe to conversation.* events on instance context The instance-level subscription was listening for message.created/updated/ deleted, which are published on conversation contexts and never fire here. Replace them with conversation.renamed, conversation.read, and conversation.activity — the events kilo-chat actually pushes to the instance context — so list updates (title, unread, last-activity) invalidate the conversations query as intended. * chore(mobile): add @shopify/flash-list dependency Required by the kilo-chat MessageList and ConversationListScreen components. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL These were declared in env-keys.js by PR 5a but never added to apps/mobile/.env, which broke the dev build. * feat(mobile): add EmptyConversationList * feat(mobile): add ConversationHeader * feat(mobile): add TypingIndicator placeholder * feat(mobile): add MessageInput * feat(mobile): add MessageBubble * feat(mobile): add MessageList Implement MessageList using FlashList v2 with maintainVisibleContentPosition and startRenderingFromBottom for chat layout; wire fetchOlder via onStartReached. * feat(mobile): add ConversationScreen * feat(mobile): add ConversationListScreen * fix(mobile): address review feedback on kilo-chat components - Drop double-cast `as unknown as Href` in favor of `as Href` - Use themed `Text` from `@/components/ui/text` and local `useKiloChatClient` re-export in `MessageBubble` - Switch `crypto.randomUUID()` to `expo-crypto`'s `Crypto.randomUUID` to match existing usage in `cloud-agent-runtime.ts` --- apps/mobile/.env | 2 + apps/mobile/package.json | 1 + .../kilo-chat/conversation-header.tsx | 15 ++ .../kilo-chat/conversation-list-screen.tsx | 126 ++++++++++++ .../kilo-chat/conversation-screen.tsx | 72 +++++++ .../kilo-chat/empty-conversation-list.tsx | 28 +++ .../components/kilo-chat/message-bubble.tsx | 191 ++++++++++++++++++ .../components/kilo-chat/message-input.tsx | 56 +++++ .../src/components/kilo-chat/message-list.tsx | 67 ++++++ .../components/kilo-chat/typing-indicator.tsx | 15 ++ pnpm-lock.yaml | 60 +++++- 11 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/conversation-header.tsx create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx create mode 100644 apps/mobile/src/components/kilo-chat/conversation-screen.tsx create mode 100644 apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx create mode 100644 apps/mobile/src/components/kilo-chat/message-bubble.tsx create mode 100644 apps/mobile/src/components/kilo-chat/message-input.tsx create mode 100644 apps/mobile/src/components/kilo-chat/message-list.tsx create mode 100644 apps/mobile/src/components/kilo-chat/typing-indicator.tsx diff --git a/apps/mobile/.env b/apps/mobile/.env index 11c9f66e17..5882750319 100644 --- a/apps/mobile/.env +++ b/apps/mobile/.env @@ -5,3 +5,5 @@ CLOUD_AGENT_WS_URL=wss://cloud-agent-next.kilosessions.ai SESSION_INGEST_WS_URL=wss://ingest.kilosessions.ai APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 APPSFLYER_APP_ID=6761193135 +EXPO_PUBLIC_KILO_CHAT_URL=https://kilo-chat.kilosessions.ai +EXPO_PUBLIC_EVENT_SERVICE_URL=wss://event-service.kilosessions.ai diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a59df0c47e..c210d87966 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -32,6 +32,7 @@ "@rn-primitives/portal": "^1.3.0", "@rn-primitives/slot": "^1.2.0", "@sentry/react-native": "~7.11.0", + "@shopify/flash-list": "2.0.2", "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", diff --git a/apps/mobile/src/components/kilo-chat/conversation-header.tsx b/apps/mobile/src/components/kilo-chat/conversation-header.tsx new file mode 100644 index 0000000000..bd69c1f476 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-header.tsx @@ -0,0 +1,15 @@ +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; + +type Props = { title: string; subtitle?: string }; + +export function ConversationHeader({ title, subtitle }: Props) { + return ( + {subtitle} : undefined + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx new file mode 100644 index 0000000000..029e6c5e64 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -0,0 +1,126 @@ +import { FlashList } from '@shopify/flash-list'; +import { type Href, useRouter } from 'expo-router'; +import { Pressable, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { timeAgo } from '@/lib/utils'; + +import { EmptyConversationList } from './empty-conversation-list'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useConversations, useCreateConversation } from './hooks/use-conversations'; +import { useInstanceEventSubscription } from './hooks/use-instance-event-subscription'; +import { useInstancePresence } from './hooks/use-instance-presence'; + +type Props = { + sandboxId: string; + sandboxLabel: string; +}; + +type ConversationItem = { + conversationId: string; + title: string | null; + lastActivityAt: number | null; + lastReadAt: number | null; + joinedAt: number; +}; + +type ConversationRowProps = { + item: ConversationItem; + onPress: (id: string) => void; +}; + +function ConversationRow({ item, onPress }: ConversationRowProps) { + const hasUnread = + item.lastActivityAt !== null && + (item.lastReadAt === null || item.lastReadAt < item.lastActivityAt); + + return ( + { + onPress(item.conversationId); + }} + > + + + {item.title ?? 'Untitled conversation'} + + {item.lastActivityAt !== null ? ( + + {timeAgo(new Date(item.lastActivityAt))} + + ) : null} + + {hasUnread ? ( + + ) : ( + // Reserve space so rows stay the same width whether the dot is shown or not + + )} + + ); +} + +export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { + const router = useRouter(); + const client = useKiloChatClient(); + const listQuery = useConversations(client, sandboxId); + const createConversation = useCreateConversation(client); + + const conversations = listQuery.data?.conversations ?? []; + const isFetchingNextPage = listQuery.isFetchingNextPage; + const fetchNextPage = listQuery.fetchNextPage; + + useInstanceEventSubscription(sandboxId); + useInstancePresence(sandboxId); + + function handleRowPress(conversationId: string) { + // Route lands in PR 5d (Task 47) + router.push(`/(app)/chat/${sandboxId}/${conversationId}` as Href); + } + + function handleCreateAndNavigate() { + createConversation.mutate( + { sandboxId }, + { + onSuccess: result => { + // Route lands in PR 5d (Task 47) + router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as Href); + }, + } + ); + } + + return ( + + + + c.conversationId} + renderItem={({ item }) => } + ListEmptyComponent={ + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + onEndReached={() => { + void fetchNextPage(); + }} + onEndReachedThreshold={0.5} + /> + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx new file mode 100644 index 0000000000..ac42c770a8 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -0,0 +1,72 @@ +import * as Crypto from 'expo-crypto'; +import { useCallback } from 'react'; +import { KeyboardAvoidingView, Platform, View } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +import { ConversationHeader } from './conversation-header'; +import { MessageInput } from './message-input'; +import { MessageList } from './message-list'; +import { TypingIndicator } from './typing-indicator'; +import { useConversationPresence } from './hooks/use-conversation-presence'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useMarkRead } from './hooks/use-mark-read'; +import { useMessages, useSendMessage } from './hooks/use-messages'; +import { useCurrentUserId } from './hooks/use-current-user-id'; + +type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; + +export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { + const client = useKiloChatClient(); + const currentUserId = useCurrentUserId(); + + const messagesQuery = useMessages(client, conversationId); + const messages = messagesQuery.data?.messages ?? []; + const hasOlder = messagesQuery.hasNextPage; + const fetchOlder = useCallback(() => { + if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { + void messagesQuery.fetchNextPage(); + } + }, [messagesQuery]); + + const sendMutation = useSendMessage(client, conversationId, currentUserId ?? ''); + const handleSend = useCallback( + (text: string) => { + sendMutation.mutate({ + conversationId, + content: [{ type: 'text', text }], + clientId: Crypto.randomUUID(), + }); + }, + [sendMutation, conversationId] + ); + + useConversationPresence(sandboxId, conversationId); + + const markRead = useMarkRead(); + useFocusEffect( + useCallback(() => { + markRead(sandboxId, conversationId); + // Active-conversation suppression wiring added in PR 5d (Task 50). + }, [sandboxId, conversationId, markRead]) + ); + + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx new file mode 100644 index 0000000000..332fee2c3d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx @@ -0,0 +1,28 @@ +import { MessageSquarePlus } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; + +type Props = { + onStart: () => void; + isStarting: boolean; +}; + +export function EmptyConversationList({ onStart, isStarting }: Props) { + return ( + + + {isStarting ? 'Starting…' : 'Start a conversation'} + + } + /> + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx new file mode 100644 index 0000000000..211264261a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -0,0 +1,191 @@ +import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { Pressable, View } from 'react-native'; + +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +type Props = { + message: Message; + conversationId: string; + isFromMe: boolean; + showAuthor: boolean; + onLongPress?: (m: Message) => void; +}; + +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +function actionStyleToVariant( + style: 'primary' | 'danger' | 'secondary' +): 'default' | 'destructive' | 'secondary' { + if (style === 'danger') { + return 'destructive'; + } + if (style === 'secondary') { + return 'secondary'; + } + return 'default'; +} + +export function MessageBubble({ + message, + conversationId, + isFromMe, + showAuthor, + onLongPress, +}: Props) { + const client = useKiloChatClient(); + const currentUserId = useCurrentUserId(); + + const executeAction = useExecuteAction(client, conversationId, currentUserId ?? ''); + const addReaction = useAddReaction(client, conversationId, currentUserId ?? ''); + const removeReaction = useRemoveReaction(client, conversationId, currentUserId ?? ''); + + const isPending = message.id.startsWith('pending-'); + const timestamp = message.clientUpdatedAt ?? message.updatedAt; + + function handleReactionPress(emoji: string) { + if (!currentUserId) { + return; + } + const hasReacted = message.reactions + .find(r => r.emoji === emoji) + ?.memberIds.includes(currentUserId); + if (hasReacted) { + removeReaction.mutate({ messageId: message.id, emoji }); + } else { + addReaction.mutate({ messageId: message.id, emoji }); + } + } + + function handleExecuteAction(groupId: string, value: ExecApprovalDecision) { + executeAction.mutate({ messageId: message.id, groupId, value }); + } + + const textColor = isFromMe ? 'text-primary-foreground' : 'text-foreground'; + + return ( + { + onLongPress(message); + } + : undefined + } + className={cn('px-4 py-1', isFromMe ? 'items-end' : 'items-start', isPending && 'opacity-50')} + > + {showAuthor && ( + + {message.senderId} + {timestamp !== null && ( + {formatTimestamp(timestamp)} + )} + + )} + + + {message.deleted ? ( + [deleted message] + ) : ( + <> + {message.content.map((block, index) => { + if (block.type === 'text') { + return ( + + {block.text} + + ); + } + + // block.type === 'actions' + if (block.resolved) { + const resolvedAction = block.actions.find(a => a.value === block.resolved?.value); + const label = resolvedAction?.label ?? block.resolved.value; + return ( + + {label} + + ); + } + + return ( + + {block.actions.map(action => ( + + ))} + + ); + })} + + )} + + {!showAuthor && timestamp !== null && ( + + {formatTimestamp(timestamp)} + + )} + + + {message.reactions.length > 0 && ( + + {message.reactions.map(reaction => { + const hasReacted = currentUserId ? reaction.memberIds.includes(currentUserId) : false; + return ( + { + handleReactionPress(reaction.emoji); + }} + className={cn( + 'flex-row items-center gap-0.5 rounded-full px-2 py-0.5', + hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' + )} + > + {reaction.emoji} + + {reaction.count} + + + ); + })} + + )} + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx new file mode 100644 index 0000000000..9e6d0bf5eb --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -0,0 +1,56 @@ +import { Send } from 'lucide-react-native'; +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { cn } from '@/lib/utils'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type Props = { + onSend: (text: string) => void; + disabled?: boolean; +}; + +export function MessageInput({ onSend, disabled }: Props) { + const colors = useThemeColors(); + const valueRef = useRef(''); + const [canSend, setCanSend] = useState(false); + const inputRef = useRef(null); + + const submit = () => { + const text = valueRef.current.trim(); + if (!text) { + return; + } + onSend(text); + valueRef.current = ''; + inputRef.current?.clear(); + setCanSend(false); + }; + + return ( + + { + valueRef.current = t; + setCanSend(t.trim().length > 0); + }} + onSubmitEditing={submit} + /> + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx new file mode 100644 index 0000000000..b418e76961 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -0,0 +1,67 @@ +import { FlashList } from '@shopify/flash-list'; +import { type Message } from '@kilocode/kilo-chat'; +import { View } from 'react-native'; + +import { MessageBubble } from '@/components/kilo-chat/message-bubble'; +import { Skeleton } from '@/components/ui/skeleton'; + +type Props = { + messages: Message[]; + conversationId: string; + currentUserId: string | null; + fetchOlder?: () => void; + hasOlder?: boolean; + onLongPressMessage?: (m: Message) => void; +}; + +export function MessageList({ + messages, + conversationId, + currentUserId, + fetchOlder, + hasOlder, + onLongPressMessage, +}: Props) { + // useMessages returns messages newest-first (result of .reverse() in the hook). + // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition + // with startRenderingFromBottom. That requires data in chronological order (oldest first), + // so we reverse once to get oldest→newest. + const chronological = messages.toReversed(); + + return ( + { + // In chronological order, the previous message in time is data[index - 1]. + // showAuthor is true when the sender changes relative to the prior message, + // or when this is the oldest message (index 0). + const previousItem = chronological[index - 1]; + const showAuthor = previousItem === undefined || previousItem.senderId !== item.senderId; + + return ( + + ); + }} + keyExtractor={item => item.id} + onStartReached={fetchOlder} + onStartReachedThreshold={0.5} + maintainVisibleContentPosition={{ + // Start rendering from the bottom so the newest message is visible on first render. + startRenderingFromBottom: true, + }} + ListHeaderComponent={ + hasOlder ? ( + + + + ) : null + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator.tsx b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx new file mode 100644 index 0000000000..c909788c14 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx @@ -0,0 +1,15 @@ +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +type Props = { isTyping: boolean; name?: string }; + +export function TypingIndicator({ isTyping, name }: Props) { + if (!isTyping) { + return null; + } + return ( + + {name ?? 'Bot'} is typing… + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b252a8a276..fde8db827f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: '@sentry/react-native': specifier: ~7.11.0 version: 7.11.0(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) + '@shopify/flash-list': + specifier: 2.0.2 + version: 2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) '@tailwindcss/postcss': specifier: ^4.2.2 version: 4.2.2 @@ -328,7 +331,7 @@ importers: version: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) stream-chat-expo: specifier: ^8.13.7 - version: 8.13.7(f3af0588b693ec71c0fc67ae0290618e) + version: 8.13.7(e673e8bffb1896cc06607271df6a38dc) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -1601,7 +1604,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -6814,6 +6817,13 @@ packages: peerDependencies: webpack: '>=5.0.0' + '@shopify/flash-list@2.0.2': + resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -20523,6 +20533,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@shopify/flash-list@2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.0 + react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + tslib: 2.8.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -25729,6 +25746,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -26380,6 +26416,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -29668,12 +29717,12 @@ snapshots: stream-buffers@2.2.0: {} - stream-chat-expo@8.13.7(f3af0588b693ec71c0fc67ae0290618e): + stream-chat-expo@8.13.7(e673e8bffb1896cc06607271df6a38dc): dependencies: expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) expo-image-manipulator: 55.0.14(expo@55.0.12) mime: 4.1.0 - stream-chat-react-native-core: 8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(ab8a9f6f835746e18b11cebeae560e08) + stream-chat-react-native-core: 8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8) optionalDependencies: expo-audio: 55.0.12(expo-asset@55.0.13(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3))(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) expo-clipboard: 55.0.12(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -29702,7 +29751,7 @@ snapshots: - typescript - utf-8-validate - stream-chat-react-native-core@8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(ab8a9f6f835746e18b11cebeae560e08): + stream-chat-react-native-core@8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8): dependencies: '@gorhom/bottom-sheet': 5.1.8(patch_hash=c5eaae9a28f5662f32d66e0129609680309eddc44720d6a2b1e02bbc2b5dd11f)(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) '@react-native-community/netinfo': 11.5.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -29726,6 +29775,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.0) optionalDependencies: '@emoji-mart/data': 1.2.1 + '@shopify/flash-list': 2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) emoji-mart: 5.6.0 transitivePeerDependencies: - '@types/react' From 5dd00053e8c9414657d15bbb62327bf6dbc91225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:42:27 +0200 Subject: [PATCH 016/289] =?UTF-8?q?feat(mobile):=20PR=205d=20=E2=80=94=20k?= =?UTF-8?q?ilo-chat=20routes=20+=20deep=20links=20(#2953)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. * feat(mobile): add usePresenceSubscription primitive * feat(mobile): subscribe to /presence/app while app is active * feat(mobile): add useInstancePresence hook * feat(mobile): add useConversationPresence hook * fix(mobile): fix lint errors in presence hooks * feat(mobile): add useEventSubscription primitive * feat(mobile): add useInstanceEventSubscription * fix(mobile): apply curly/switch-case-braces lint rules to event hooks * feat(kilo-chat-hooks): create shared package; extract useConversations * feat(kilo-chat-hooks): extract useMessages — base query + optimistic send Move PAGE_SIZE, helper functions (applyReactionAdded/Removed, restoreMessageInCache, removeMessageFromCache, findMessageInCache), useMessages infinite-query hook, and useSendMessage mutation into @kilocode/kilo-chat-hooks. Web's useMessages.ts re-exports the moved hooks and retains local helper copies for remaining mutations (37b will collapse). * feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations * feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared package Moves the live event-stream cache patcher from the web-only useMessages file into @kilocode/kilo-chat-hooks. Adds an optional onActionFailed callback so platform wrappers inject toasts; web passes toast.error. * feat(mobile): wire shared kilo-chat-hooks + platform adapters * fix(kilo-chat-hooks): centralize query keys; tighten event-subscription API - Add packages/kilo-chat-hooks/src/query-keys.ts with conversations/ conversation/messages/bot-status helpers; route every hook + invalidator through it. Fixes the mobile useInstanceEventSubscription bug where invalidations used ['conversations', sandboxId] but the queries register under ['kilo-chat', 'conversations', sandboxId], so list previews and unread counts never refreshed on incoming events. - useEventSubscription now takes a single event name; callers register one hook per event. Drops the events.join('|') dependency hack and the eslint-disable. useInstanceEventSubscription becomes six explicit registrations. - Drop the hardcoded English toast string from useMessageCacheUpdater; onActionFailed is () => void and the message lives at each call site. - Extract useAppActiveAndFocused to deduplicate AppState+focus boilerplate shared by useInstancePresence and useConversationPresence. * fix(mobile): subscribe to conversation.* events on instance context The instance-level subscription was listening for message.created/updated/ deleted, which are published on conversation contexts and never fire here. Replace them with conversation.renamed, conversation.read, and conversation.activity — the events kilo-chat actually pushes to the instance context — so list updates (title, unread, last-activity) invalidate the conversations query as intended. * chore(mobile): add @shopify/flash-list dependency Required by the kilo-chat MessageList and ConversationListScreen components. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL These were declared in env-keys.js by PR 5a but never added to apps/mobile/.env, which broke the dev build. * feat(mobile): add EmptyConversationList * feat(mobile): add ConversationHeader * feat(mobile): add TypingIndicator placeholder * feat(mobile): add MessageInput * feat(mobile): add MessageBubble * feat(mobile): add MessageList Implement MessageList using FlashList v2 with maintainVisibleContentPosition and startRenderingFromBottom for chat layout; wire fetchOlder via onStartReached. * feat(mobile): add ConversationScreen * feat(mobile): add ConversationListScreen * fix(mobile): address review feedback on kilo-chat components - Drop double-cast `as unknown as Href` in favor of `as Href` - Use themed `Text` from `@/components/ui/text` and local `useKiloChatClient` re-export in `MessageBubble` - Switch `crypto.randomUUID()` to `expo-crypto`'s `Crypto.randomUUID` to match existing usage in `cloud-agent-runtime.ts` * feat(mobile): add chat sandbox stack layout * feat(mobile): add conversation list route * feat(mobile): add conversation message route * feat(mobile): wire chat deep links and active-conversation suppression * fix(mobile): clear correct badge bucket on legacy chat foreground push --- .../chat/[sandbox-id]/[conversation-id].tsx | 20 ++++++++ .../app/(app)/chat/[sandbox-id]/_layout.tsx | 5 ++ .../src/app/(app)/chat/[sandbox-id]/index.tsx | 11 +++++ .../kilo-chat/conversation-screen.tsx | 6 ++- apps/mobile/src/components/kiloclaw/chat.tsx | 16 +++--- .../hooks/use-unread-counts-invalidation.ts | 2 +- apps/mobile/src/lib/notifications.ts | 49 +++++++------------ packages/notifications/src/badge-buckets.ts | 2 - 8 files changed, 67 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx new file mode 100644 index 0000000000..a81779108f --- /dev/null +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx @@ -0,0 +1,20 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { ConversationScreen } from '@/components/kilo-chat/conversation-screen'; +import { useConversationDetail } from '@/components/kilo-chat/hooks/use-conversations'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; + +export default function ChatConversationRoute() { + const params = useLocalSearchParams<{ 'sandbox-id': string; 'conversation-id': string }>(); + const sandboxId = params['sandbox-id']; + const conversationId = params['conversation-id']; + const client = useKiloChatClient(); + const { data } = useConversationDetail(client, conversationId); + return ( + + ); +} diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx new file mode 100644 index 0000000000..6d1a690211 --- /dev/null +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function ChatSandboxLayout() { + return ; +} diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx new file mode 100644 index 0000000000..d99324b468 --- /dev/null +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { ConversationListScreen } from '@/components/kilo-chat/conversation-list-screen'; +import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; + +export default function ChatSandboxIndex() { + const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); + const { data: instances } = useAllKiloClawInstances(); + const sandboxLabel = instances?.find(i => i.sandboxId === sandboxId)?.name ?? 'Chat'; + return ; +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index ac42c770a8..000c4a76a7 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -12,6 +12,7 @@ import { useKiloChatClient } from './hooks/use-kilo-chat-client'; import { useMarkRead } from './hooks/use-mark-read'; import { useMessages, useSendMessage } from './hooks/use-messages'; import { useCurrentUserId } from './hooks/use-current-user-id'; +import { setActiveChatLocation } from '@/lib/notifications'; type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; @@ -46,7 +47,10 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl useFocusEffect( useCallback(() => { markRead(sandboxId, conversationId); - // Active-conversation suppression wiring added in PR 5d (Task 50). + setActiveChatLocation({ sandboxId, conversationId }); + return () => { + setActiveChatLocation(null); + }; }, [sandboxId, conversationId, markRead]) ); diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx index e75b60862b..709ddaba30 100644 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ b/apps/mobile/src/components/kiloclaw/chat.tsx @@ -9,6 +9,8 @@ import { type Channel as StreamChannel, StreamChat } from 'stream-chat'; import { Channel, Chat, MessageInput, MessageList, OverlayProvider } from 'stream-chat-expo'; import { toast } from 'sonner-native'; +import { badgeBucketForConversation } from '@kilocode/notifications'; + import { KiloClawMessageAvatar } from '@/components/kiloclaw/chat-avatar'; import { ChatPlaceholder } from '@/components/kiloclaw/chat-placeholder'; import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell'; @@ -19,11 +21,7 @@ import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; import { setLastActiveInstance } from '@/lib/last-active-instance'; -import { - getNotificationSandboxId, - parseNotificationData, - setActiveChatInstance, -} from '@/lib/notifications'; +import { parseNotificationData } from '@/lib/notifications'; import { useTRPC } from '@/lib/trpc'; type KiloClawChatProps = { @@ -78,7 +76,6 @@ export function KiloClawChat({ useCallback(() => { const badgeBucket = badgeBucketForInstance(instanceId); isFocusedRef.current = true; - setActiveChatInstance(instanceId); setLastActiveInstance(instanceId); markChatRead({ badgeBucket }); @@ -87,14 +84,15 @@ export function KiloClawChat({ // it immediately so the badge never drifts above 0 while the user is reading. const subscription = Notifications.addNotificationReceivedListener(notification => { const data = parseNotificationData(notification.request.content.data); - if (data && getNotificationSandboxId(data) === instanceId) { - markChatRead({ badgeBucket }); + if (data?.type === 'chat.message' && data.sandboxId === instanceId) { + markChatRead({ + badgeBucket: badgeBucketForConversation(data.sandboxId, data.conversationId), + }); } }); return () => { isFocusedRef.current = false; - setActiveChatInstance(null); subscription.remove(); }; }, [instanceId, markChatRead]) diff --git a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts index b6b361ae9c..4d9846244e 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts @@ -33,7 +33,7 @@ export function useUnreadCountsInvalidation() { const received = Notifications.addNotificationReceivedListener(notification => { const data = parseNotificationData(notification.request.content.data); - if (data?.type === 'chat') { + if (data?.type === 'chat.message') { invalidate(); } }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 7045ad9b22..273680ff92 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -2,7 +2,8 @@ import expoConstants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { type Href, router } from 'expo-router'; import { Platform } from 'react-native'; -import { z } from 'zod'; + +import { pushDataSchema } from '@kilocode/notifications'; function getProjectId(): string { const eas = expoConstants.expoConfig?.extra?.eas as { projectId?: string } | undefined; @@ -13,44 +14,27 @@ function getProjectId(): string { return projectId; } -// Tracks which chat instance screen is currently focused. +// Tracks which conversation screen is currently focused. // Read by the foreground notification handler to suppress notifications -// when the user is already viewing that chat. +// when the user is already viewing that conversation. // A module-level variable (not React state) because the notification handler // is registered once and must always read the latest value without stale closures. -let activeChatInstanceId: string | null = null; +let activeChatLocation: { sandboxId: string; conversationId: string } | null = null; -export function setActiveChatInstance(instanceId: string | null) { - activeChatInstanceId = instanceId; +export function setActiveChatLocation( + location: { sandboxId: string; conversationId: string } | null +) { + activeChatLocation = location; } -const notificationDataSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('chat'), - instanceId: z.string().min(1), - }), - z.object({ - type: z.literal('chat.message'), - sandboxId: z.string().min(1), - conversationId: z.string().min(1), - messageId: z.string().min(1), - }), -]); - -type NotificationData = z.infer; - // Runtime-validates that an arbitrary notification `data` payload matches the // shape we care about. Push producers can evolve independently of the app, so // always parse before reading fields from the OS-provided notification content. -export function parseNotificationData(data: unknown): NotificationData | null { - const parsed = notificationDataSchema.safeParse(data); +export function parseNotificationData(data: unknown) { + const parsed = pushDataSchema.safeParse(data); return parsed.success ? parsed.data : null; } -export function getNotificationSandboxId(data: NotificationData): string { - return data.type === 'chat' ? data.instanceId : data.sandboxId; -} - const shown = { shouldShowAlert: true, shouldPlaySound: true, @@ -73,8 +57,11 @@ export function setupNotificationHandler() { handleNotification: async notification => { const data = parseNotificationData(notification.request.content.data); - // Suppress only if the user is already viewing this exact chat - if (data && getNotificationSandboxId(data) === activeChatInstanceId) { + if ( + data?.type === 'chat.message' && + activeChatLocation?.sandboxId === data.sandboxId && + activeChatLocation.conversationId === data.conversationId + ) { return suppressed; } @@ -98,7 +85,7 @@ export function setupNotificationResponseHandler() { const data = parseNotificationData(response.notification.request.content.data); if (data) { - const path = `/(app)/chat/${getNotificationSandboxId(data)}`; + const path = `/(app)/chat/${data.sandboxId}/${data.conversationId}`; // If the router is ready (has segments), navigate immediately. // Otherwise store as pending for consumption after auth completes. try { @@ -120,7 +107,7 @@ export function checkInitialNotification(): void { } const data = parseNotificationData(response.notification.request.content.data); if (data) { - pendingNotificationLink = `/(app)/chat/${getNotificationSandboxId(data)}`; + pendingNotificationLink = `/(app)/chat/${data.sandboxId}/${data.conversationId}`; } } diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts index bb3213307c..56917b3d34 100644 --- a/packages/notifications/src/badge-buckets.ts +++ b/packages/notifications/src/badge-buckets.ts @@ -5,7 +5,5 @@ * don't collide as more surfaces start emitting badge updates. */ -export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const; - export const badgeBucketForConversation = (sandboxId: string, conversationId: string) => `kiloclaw:${sandboxId}:${conversationId}` as const; From a67ddf99288cc4c171c9a88341a204f9b6fa34b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:50:52 +0200 Subject: [PATCH 017/289] =?UTF-8?q?feat:=20PR=206=20=E2=80=94=20Stream=20r?= =?UTF-8?q?ip=20+=20final=20validation=20(#2958)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. * feat(mobile): add usePresenceSubscription primitive * feat(mobile): subscribe to /presence/app while app is active * feat(mobile): add useInstancePresence hook * feat(mobile): add useConversationPresence hook * fix(mobile): fix lint errors in presence hooks * feat(mobile): add useEventSubscription primitive * feat(mobile): add useInstanceEventSubscription * fix(mobile): apply curly/switch-case-braces lint rules to event hooks * feat(kilo-chat-hooks): create shared package; extract useConversations * feat(kilo-chat-hooks): extract useMessages — base query + optimistic send Move PAGE_SIZE, helper functions (applyReactionAdded/Removed, restoreMessageInCache, removeMessageFromCache, findMessageInCache), useMessages infinite-query hook, and useSendMessage mutation into @kilocode/kilo-chat-hooks. Web's useMessages.ts re-exports the moved hooks and retains local helper copies for remaining mutations (37b will collapse). * feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations * feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared package Moves the live event-stream cache patcher from the web-only useMessages file into @kilocode/kilo-chat-hooks. Adds an optional onActionFailed callback so platform wrappers inject toasts; web passes toast.error. * feat(mobile): wire shared kilo-chat-hooks + platform adapters * fix(kilo-chat-hooks): centralize query keys; tighten event-subscription API - Add packages/kilo-chat-hooks/src/query-keys.ts with conversations/ conversation/messages/bot-status helpers; route every hook + invalidator through it. Fixes the mobile useInstanceEventSubscription bug where invalidations used ['conversations', sandboxId] but the queries register under ['kilo-chat', 'conversations', sandboxId], so list previews and unread counts never refreshed on incoming events. - useEventSubscription now takes a single event name; callers register one hook per event. Drops the events.join('|') dependency hack and the eslint-disable. useInstanceEventSubscription becomes six explicit registrations. - Drop the hardcoded English toast string from useMessageCacheUpdater; onActionFailed is () => void and the message lives at each call site. - Extract useAppActiveAndFocused to deduplicate AppState+focus boilerplate shared by useInstancePresence and useConversationPresence. * fix(mobile): subscribe to conversation.* events on instance context The instance-level subscription was listening for message.created/updated/ deleted, which are published on conversation contexts and never fire here. Replace them with conversation.renamed, conversation.read, and conversation.activity — the events kilo-chat actually pushes to the instance context — so list updates (title, unread, last-activity) invalidate the conversations query as intended. * chore(mobile): add @shopify/flash-list dependency Required by the kilo-chat MessageList and ConversationListScreen components. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL These were declared in env-keys.js by PR 5a but never added to apps/mobile/.env, which broke the dev build. * feat(mobile): add EmptyConversationList * feat(mobile): add ConversationHeader * feat(mobile): add TypingIndicator placeholder * feat(mobile): add MessageInput * feat(mobile): add MessageBubble * feat(mobile): add MessageList Implement MessageList using FlashList v2 with maintainVisibleContentPosition and startRenderingFromBottom for chat layout; wire fetchOlder via onStartReached. * feat(mobile): add ConversationScreen * feat(mobile): add ConversationListScreen * fix(mobile): address review feedback on kilo-chat components - Drop double-cast `as unknown as Href` in favor of `as Href` - Use themed `Text` from `@/components/ui/text` and local `useKiloChatClient` re-export in `MessageBubble` - Switch `crypto.randomUUID()` to `expo-crypto`'s `Crypto.randomUUID` to match existing usage in `cloud-agent-runtime.ts` * feat(mobile): add chat sandbox stack layout * feat(mobile): add conversation list route * feat(mobile): add conversation message route * feat(mobile): wire chat deep links and active-conversation suppression * fix(mobile): clear correct badge bucket on legacy chat foreground push * chore(mobile): delete Stream-based chat components and routes * chore(mobile): remove useStreamChatCredentials hook * chore: remove stream-chat deps and RN patch * chore(web): remove Stream tRPC procedures * chore(web): delete Stream chat-credentials API route * chore(web): strip Stream methods from kiloclaw clients * chore(web): replace ChatTab with redirect, drop Stream hooks * chore(kiloclaw): delete src/stream-chat directory * chore(kiloclaw): remove Stream injections from instance DO and routes * chore(kiloclaw): remove Stream from controller config-writer * chore(kiloclaw): drop STREAM_CHAT_* secret bindings * chore(web): remove residual Stream CSS and npm deps * chore(mobile): drop unused exports and deps flagged by knip --- apps/mobile/package.json | 6 +- .../src/app/(app)/chat/[instance-id].tsx | 25 - .../src/components/home/kiloclaw-card.tsx | 36 - .../kilo-chat/hooks/use-conversations.ts | 6 - .../kilo-chat/hooks/use-messages.ts | 12 +- .../kilo-chat/kilo-chat-provider.tsx | 31 +- .../src/components/kiloclaw/chat-avatar.tsx | 30 - .../src/components/kiloclaw/chat-hooks.ts | 31 - .../components/kiloclaw/chat-placeholder.tsx | 11 - .../src/components/kiloclaw/chat-shell.tsx | 84 -- .../src/components/kiloclaw/chat-theme.ts | 70 -- apps/mobile/src/components/kiloclaw/chat.tsx | 300 ------- .../kiloclaw/notification-prompt.tsx | 121 --- .../components/kiloclaw/onboarding-flow.tsx | 2 +- apps/mobile/src/lib/badge-buckets.ts | 3 +- .../lib/hooks/use-kiloclaw-latest-message.ts | 87 -- .../src/lib/hooks/use-kiloclaw-queries.ts | 18 - .../mobile/src/lib/hooks/use-unread-counts.ts | 11 +- apps/mobile/src/lib/last-active-instance.ts | 5 - apps/web/package.json | 2 - apps/web/src/app/(app)/claw/claw-chat.css | 153 ---- .../src/app/(app)/claw/components/ChatTab.tsx | 231 +---- .../(app)/claw/components/ClawChatPage.tsx | 5 +- .../src/app/(app)/claw/hooks/useClawHooks.ts | 24 - apps/web/src/app/(app)/claw/layout.tsx | 1 - .../(app)/organizations/[id]/claw/layout.tsx | 1 - .../api/kiloclaw/chat-credentials/route.ts | 71 -- apps/web/src/hooks/useKiloClaw.ts | 10 - .../lib/kiloclaw/kiloclaw-internal-client.ts | 31 - .../src/lib/kiloclaw/kiloclaw-user-client.ts | 11 +- apps/web/src/lib/kiloclaw/types.ts | 8 - apps/web/src/routers/kiloclaw-router.ts | 72 -- .../kiloclaw-send-chat-message.test.ts | 247 ------ .../organization-kiloclaw-router.ts | 33 - package.json | 3 - patches/@gorhom__bottom-sheet@5.1.8.patch | 97 --- patches/stream-chat-react-native-core.patch | 65 -- pnpm-lock.yaml | 790 +----------------- .../controller/src/config-writer.test.ts | 62 -- .../kiloclaw/controller/src/config-writer.ts | 28 - .../durable-objects/kiloclaw-instance.test.ts | 225 ----- .../kiloclaw-instance/config.ts | 11 - .../kiloclaw-instance/index.ts | 128 --- .../kiloclaw-instance/state.ts | 12 - .../kiloclaw-instance/types.ts | 5 - services/kiloclaw/src/gateway/env.ts | 2 - services/kiloclaw/src/routes/kiloclaw.ts | 26 - services/kiloclaw/src/routes/platform.ts | 94 --- .../kiloclaw/src/schemas/instance-config.ts | 6 - .../kiloclaw/src/stream-chat/client.test.ts | 477 ----------- services/kiloclaw/src/stream-chat/client.ts | 299 ------- services/kiloclaw/src/types.ts | 4 - services/kiloclaw/worker-configuration.d.ts | 51 +- 53 files changed, 55 insertions(+), 4119 deletions(-) delete mode 100644 apps/mobile/src/app/(app)/chat/[instance-id].tsx delete mode 100644 apps/mobile/src/components/kiloclaw/chat-avatar.tsx delete mode 100644 apps/mobile/src/components/kiloclaw/chat-hooks.ts delete mode 100644 apps/mobile/src/components/kiloclaw/chat-placeholder.tsx delete mode 100644 apps/mobile/src/components/kiloclaw/chat-shell.tsx delete mode 100644 apps/mobile/src/components/kiloclaw/chat-theme.ts delete mode 100644 apps/mobile/src/components/kiloclaw/chat.tsx delete mode 100644 apps/mobile/src/components/kiloclaw/notification-prompt.tsx delete mode 100644 apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts delete mode 100644 apps/web/src/app/(app)/claw/claw-chat.css delete mode 100644 apps/web/src/app/api/kiloclaw/chat-credentials/route.ts delete mode 100644 apps/web/src/routers/kiloclaw-send-chat-message.test.ts delete mode 100644 patches/@gorhom__bottom-sheet@5.1.8.patch delete mode 100644 patches/stream-chat-react-native-core.patch delete mode 100644 services/kiloclaw/src/stream-chat/client.test.ts delete mode 100644 services/kiloclaw/src/stream-chat/client.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c210d87966..061695e27d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -54,7 +54,6 @@ "expo-font": "~55.0.6", "expo-haptics": "~55.0.13", "expo-image": "~55.0.8", - "expo-image-manipulator": "~55.0.14", "expo-image-picker": "~55.0.17", "expo-insights": "55.0.15", "expo-linking": "~55.0.11", @@ -83,11 +82,8 @@ "react-native-svg": "15.15.3", "react-native-worklets": "0.7.2", "sonner-native": "^0.23.1", - "stream-chat": "catalog:", - "stream-chat-expo": "^8.13.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", - "zod": "catalog:" + "tailwindcss": "^4.2.2" }, "devDependencies": { "@sentry/cli": "catalog:", diff --git a/apps/mobile/src/app/(app)/chat/[instance-id].tsx b/apps/mobile/src/app/(app)/chat/[instance-id].tsx deleted file mode 100644 index 3203d543f3..0000000000 --- a/apps/mobile/src/app/(app)/chat/[instance-id].tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useLocalSearchParams } from 'expo-router'; -import { View } from 'react-native'; - -import { KiloClawChat } from '@/components/kiloclaw/chat'; -import { useInstanceContext } from '@/lib/hooks/use-instance-context'; -import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; - -export default function ChatScreen() { - const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); - const { organizationId } = useInstanceContext(instanceId); - const { data: status } = useKiloClawStatus(organizationId); - const isRunning = status?.status === 'running'; - const machineName = status?.name ?? 'Chat'; - - return ( - - - - ); -} diff --git a/apps/mobile/src/components/home/kiloclaw-card.tsx b/apps/mobile/src/components/home/kiloclaw-card.tsx index 16599f6e32..c773e782a1 100644 --- a/apps/mobile/src/components/home/kiloclaw-card.tsx +++ b/apps/mobile/src/components/home/kiloclaw-card.tsx @@ -6,9 +6,7 @@ import { isTransitionalStatus, statusLabel, statusTone } from '@/components/kilo import { StatusDot } from '@/components/ui/status-dot'; import { Text } from '@/components/ui/text'; import { agentColor } from '@/lib/agent-color'; -import { useKiloClawLatestMessage } from '@/lib/hooks/use-kiloclaw-latest-message'; import { useKiloClawStatus, useKiloClawStatusQueryKey } from '@/lib/hooks/use-kiloclaw-queries'; -import { parseTimestamp } from '@/lib/utils'; type KiloClawCardProps = { instance: { @@ -27,25 +25,6 @@ function formatUnreadCount(count: number): string { return count > 99 ? '99+' : String(count); } -function formatMessagePreview( - message: { text: string; isFromMe: boolean }, - botEmoji: string | null -): string { - const text = message.text.length > 0 ? message.text : 'New message'; - if (message.isFromMe) { - return `You: ${text}`; - } - return botEmoji ? `${botEmoji} ${text}` : text; -} - -function formatClockTime(date: Date): string { - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const period = hours >= 12 ? 'PM' : 'AM'; - const displayHours = hours % 12 === 0 ? 12 : hours % 12; - return `${String(displayHours)}:${minutes} ${period}`; -} - function firstLetter(name: string): string { const trimmed = name.trim(); return trimmed.length > 0 ? (trimmed[0]?.toUpperCase() ?? 'K') : 'K'; @@ -69,7 +48,6 @@ export function KiloClawCard({ instance, unreadCount = 0 }: Readonly 0; const accessibilityLabel = hasUnread @@ -118,11 +95,6 @@ export function KiloClawCard({ instance, unreadCount = 0 }: Readonly {displayName} - {lastMessageTime ? ( - - {lastMessageTime} - - ) : null} @@ -137,14 +109,6 @@ export function KiloClawCard({ instance, unreadCount = 0 }: Readonly ) : null} - - {latest ? ( - - - {formatMessagePreview(latest, botEmoji)} - - - ) : null} ); } diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts index 221f56e395..253bf6a85a 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts @@ -2,10 +2,4 @@ export { useConversations, useConversationDetail, useCreateConversation, - useRenameConversation, - useLeaveConversation, - useMarkConversationRead, - updateConversationPages, - filterConversationPages, } from '@kilocode/kilo-chat-hooks'; -export type { ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts index 2f5fb31ec6..ac26bc57f6 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts @@ -1,11 +1 @@ -export { - useMessages, - useSendMessage, - useEditMessage, - useDeleteMessage, - useAddReaction, - useRemoveReaction, - useExecuteAction, - useMessageCacheUpdater, -} from '@kilocode/kilo-chat-hooks'; -export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; +export { useMessages, useSendMessage } from '@kilocode/kilo-chat-hooks'; diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index 95ed84f647..d3ca76aa5f 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { EventServiceClient } from '@kilocode/event-service'; import { KiloChatClient } from '@kilocode/kilo-chat'; @@ -8,21 +8,6 @@ import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; import { useKiloChatTokenGetter } from './hooks/use-kilo-chat-token'; -type KiloChatContextValue = { - eventService: EventServiceClient; - kiloChatClient: KiloChatClient; -}; - -export const KiloChatContext = createContext(null); - -export function useKiloChatContext(): KiloChatContextValue { - const ctx = useContext(KiloChatContext); - if (!ctx) { - throw new Error('useKiloChatContext must be used within a KiloChatProvider'); - } - return ctx; -} - type KiloChatProviderProps = { children: React.ReactNode; }; @@ -30,7 +15,7 @@ type KiloChatProviderProps = { export function KiloChatProvider({ children }: KiloChatProviderProps) { const getToken = useKiloChatTokenGetter(); - const [value] = useState(() => { + const [value] = useState(() => { const eventService = new EventServiceClient({ url: EVENT_SERVICE_URL, getToken, @@ -51,12 +36,10 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { }, [value]); return ( - - - {children} - - + + {children} + ); } diff --git a/apps/mobile/src/components/kiloclaw/chat-avatar.tsx b/apps/mobile/src/components/kiloclaw/chat-avatar.tsx deleted file mode 100644 index 9445553291..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-avatar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { View } from 'react-native'; -import { type MessageAvatarProps, useMessageContext } from 'stream-chat-expo'; - -import logo from '@/../assets/images/logo.png'; -import { Image } from '@/components/ui/image'; - -export function KiloClawMessageAvatar(_props: MessageAvatarProps) { - const { message, lastGroupMessage } = useMessageContext(); - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- message can be undefined at runtime in reply swipe context - const isBotMessage = message?.user?.id?.startsWith('bot-'); - - if (!lastGroupMessage) { - return ; - } - - if (isBotMessage) { - return ( - - - - ); - } - - return ; -} diff --git a/apps/mobile/src/components/kiloclaw/chat-hooks.ts b/apps/mobile/src/components/kiloclaw/chat-hooks.ts deleted file mode 100644 index 7f32cf37bc..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useState } from 'react'; -import { type Event, type Channel as StreamChannel, type StreamChat } from 'stream-chat'; - -export function useBotOnlineStatus( - client: StreamChat | null, - channel: StreamChannel | null, - botUserId: string -): boolean { - const [online, setOnline] = useState(false); - - useEffect(() => { - const handlePresenceChange = (event: Event) => { - if (event.user?.id === botUserId) { - setOnline(Boolean(event.user.online)); - } - }; - - if (client && channel) { - // Check initial state - const member = channel.state.members[botUserId]; - setOnline(Boolean(member?.user?.online)); - client.on('user.presence.changed', handlePresenceChange); - } - - return () => { - client?.off('user.presence.changed', handlePresenceChange); - }; - }, [client, channel, botUserId]); - - return online; -} diff --git a/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx b/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx deleted file mode 100644 index 35017111fe..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-placeholder.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { View } from 'react-native'; - -import { Text } from '@/components/ui/text'; - -export function ChatPlaceholder({ message }: { message: string }) { - return ( - - {message} - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/chat-shell.tsx b/apps/mobile/src/components/kiloclaw/chat-shell.tsx deleted file mode 100644 index 6bc1ebf35a..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-shell.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { type Href, useRouter } from 'expo-router'; -import { Settings } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; - -import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; -import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -function BotStatusIndicator({ online }: { online: boolean }) { - return ( - - - {online ? 'Online' : 'Offline'} - - ); -} - -export function ChatHeader({ - instanceId, - title, - botOnline, -}: { - instanceId: string; - title: string; - botOnline?: boolean; -}) { - const router = useRouter(); - const colors = useThemeColors(); - const { data: instances } = useAllKiloClawInstances(); - - const hasMultipleInstances = (instances?.length ?? 0) > 1; - - const handleTitlePress = () => { - const href: Href = { - pathname: '/(app)/chat/instance-picker', - params: { currentId: instanceId }, - }; - router.push(href); - }; - - const settingsButton = ( - { - router.push(`/(app)/kiloclaw/${instanceId}/dashboard` as Href); - }} - hitSlop={12} - accessibilityLabel="Settings" - className="active:opacity-70" - > - - - ); - - return ( - - {botOnline !== undefined && } - {settingsButton} - - } - /> - ); -} - -export function ChatShell({ - instanceId, - name, - children, -}: { - instanceId: string; - name: string; - children: React.ReactNode; -}) { - return ( - - - {children} - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/chat-theme.ts b/apps/mobile/src/components/kiloclaw/chat-theme.ts deleted file mode 100644 index 2472ec5cbb..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat-theme.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme } from 'react-native'; -import { type DeepPartial, type Theme } from 'stream-chat-expo'; - -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -export function useStreamChatTheme(): DeepPartial { - const colorScheme = useColorScheme(); - const colors = useThemeColors(); - - const [theme, setTheme] = useState>(() => buildTheme(colorScheme, colors)); - - useEffect(() => { - setTheme(buildTheme(colorScheme, colors)); - }, [colorScheme, colors]); - - return theme; -} - -function buildTheme( - colorScheme: ReturnType, - colors: ReturnType -): DeepPartial { - return { - colors: - colorScheme === 'dark' - ? { - black: colors.foreground, - white: colors.background, - white_smoke: colors.secondary, - white_snow: colors.muted, - grey: colors.mutedForeground, - grey_dark: colors.mutedForeground, - grey_gainsboro: colors.border, - grey_whisper: colors.border, - light_blue: 'hsl(0, 0%, 20%)', - light_gray: 'hsl(0, 0%, 20%)', - blue_alice: 'hsl(0, 0%, 18%)', - text_high_emphasis: colors.foreground, - text_low_emphasis: colors.mutedForeground, - bg_gradient_start: colors.background, - bg_gradient_end: colors.secondary, - icon_background: colors.card, - overlay: 'rgba(0, 0, 0, 0.8)', - } - : {}, - dateHeader: { - container: { - backgroundColor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.12)' : undefined, - }, - text: { - color: colorScheme === 'dark' ? colors.foreground : undefined, - }, - }, - inlineDateSeparator: { - container: { - backgroundColor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.12)' : undefined, - }, - text: { - color: colorScheme === 'dark' ? colors.foreground : undefined, - }, - }, - messageInput: { - container: { - paddingHorizontal: 12, - borderColor: colors.border, - }, - }, - }; -} diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx deleted file mode 100644 index 709ddaba30..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useFocusEffect } from 'expo-router'; -import { Image as ExpoImage } from 'expo-image'; // eslint-disable-line no-restricted-imports -- raw expo-image needed for Stream Chat SDK ImageComponent prop -import * as Notifications from 'expo-notifications'; -import { type Channel as StreamChannel, StreamChat } from 'stream-chat'; -import { Channel, Chat, MessageInput, MessageList, OverlayProvider } from 'stream-chat-expo'; -import { toast } from 'sonner-native'; - -import { badgeBucketForConversation } from '@kilocode/notifications'; - -import { KiloClawMessageAvatar } from '@/components/kiloclaw/chat-avatar'; -import { ChatPlaceholder } from '@/components/kiloclaw/chat-placeholder'; -import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell'; -import { useBotOnlineStatus } from '@/components/kiloclaw/chat-hooks'; -import { NotificationPrompt } from '@/components/kiloclaw/notification-prompt'; -import { useStreamChatTheme } from '@/components/kiloclaw/chat-theme'; -import { badgeBucketForInstance } from '@/lib/badge-buckets'; -import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle'; -import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; -import { setLastActiveInstance } from '@/lib/last-active-instance'; -import { parseNotificationData } from '@/lib/notifications'; -import { useTRPC } from '@/lib/trpc'; - -type KiloClawChatProps = { - instanceId: string; - name: string; - enabled: boolean; - organizationId?: string | null; -}; - -type UnreadCountsData = { badgeBucket: string; badgeCount: number }[]; - -export function KiloClawChat({ - instanceId, - name, - enabled, - organizationId, -}: Readonly) { - const { data: creds, isLoading, error } = useStreamChatCredentials(organizationId, enabled); - const trpc = useTRPC(); - const { isActive } = useAppLifecycle(); - const isFocusedRef = useRef(false); - - const queryClient = useQueryClient(); - const unreadCountsKey = useMemo(() => trpc.user.getUnreadCounts.queryOptions().queryKey, [trpc]); - - const { mutate: markChatRead } = useMutation( - trpc.user.markChatRead.mutationOptions({ - onMutate: async ({ badgeBucket }) => { - await queryClient.cancelQueries({ queryKey: unreadCountsKey }); - const previous = queryClient.getQueryData(unreadCountsKey); - queryClient.setQueryData(unreadCountsKey, old => - (old ?? []).filter(row => row.badgeBucket !== badgeBucket) - ); - return { previous }; - }, - onSuccess: ({ badgeCount }) => { - void Notifications.setBadgeCountAsync(badgeCount); - }, - onError: (err: { message: string }, _input, context) => { - if (context?.previous) { - queryClient.setQueryData(unreadCountsKey, context.previous); - } - toast.error(err.message || 'Failed to update badge count'); - }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: unreadCountsKey }); - }, - }) - ); - - useFocusEffect( - useCallback(() => { - const badgeBucket = badgeBucketForInstance(instanceId); - isFocusedRef.current = true; - setLastActiveInstance(instanceId); - markChatRead({ badgeBucket }); - - // If a notification for this chat arrives while the screen is already open it is - // visually suppressed, but the DO still incremented the server-side count. Clear - // it immediately so the badge never drifts above 0 while the user is reading. - const subscription = Notifications.addNotificationReceivedListener(notification => { - const data = parseNotificationData(notification.request.content.data); - if (data?.type === 'chat.message' && data.sandboxId === instanceId) { - markChatRead({ - badgeBucket: badgeBucketForConversation(data.sandboxId, data.conversationId), - }); - } - }); - - return () => { - isFocusedRef.current = false; - subscription.remove(); - }; - }, [instanceId, markChatRead]) - ); - - // Clear badge when the app returns to the foreground while this chat is focused. - // Notifications received in the background do not fire the listener above, and - // useFocusEffect does not re-run on app resume (focus is a navigation concept, - // not an app-state one), so without this the badge stays stuck after backgrounding. - useEffect(() => { - if (isActive && isFocusedRef.current) { - markChatRead({ badgeBucket: badgeBucketForInstance(instanceId) }); - } - }, [isActive, instanceId, markChatRead]); - - if (!enabled) { - return ( - - - - ); - } - - if (isLoading) { - return ( - - - - - - ); - } - - if (error) { - return ( - - - - ); - } - - if (!creds) { - return ( - - - - ); - } - - return ( - - ); -} - -function StreamChatUI({ - instanceId, - name, - apiKey, - userId, - channelId, - organizationId, -}: { - instanceId: string; - name: string; - apiKey: string; - userId: string; - channelId: string; - organizationId?: string | null; -}) { - const { bottom } = useSafeAreaInsets(); - const [headerHeight, setHeaderHeight] = useState(0); - const chatTheme = useStreamChatTheme(); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const tokenProvider = useCallback(async () => { - const opts = organizationId - ? trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId }, - { staleTime: 0 } - ) - : trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { staleTime: 0 }); - const creds = await queryClient.fetchQuery(opts); - if (!creds?.userToken) { - throw new Error('Failed to fetch Stream Chat credentials'); - } - return creds.userToken; - }, [queryClient, trpc, organizationId]); - - const [client, setClient] = useState(null); - const [channel, setChannel] = useState(null); - const [connectError, setConnectError] = useState(null); - - useEffect(() => { - const chatClient = StreamChat.getInstance(apiKey); - - let cancelled = false; - setConnectError(null); - - const connect = async () => { - try { - // Await disconnect to prevent tokenManager.reset() from racing with the new connection - if (chatClient.userID) { - await chatClient.disconnectUser(); - } - if (cancelled) { - return; - } - await chatClient.connectUser({ id: userId }, tokenProvider); - const ch = chatClient.channel('messaging', channelId); - await ch.watch({ presence: true }); - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- cancelled can change across awaits - if (!cancelled) { - setClient(chatClient); - setChannel(ch); - } - } catch (error) { - if (!cancelled) { - setConnectError(error instanceof Error ? error.message : 'Failed to connect to chat.'); - } - } - }; - - void connect(); - - return () => { - cancelled = true; - setClient(null); - setChannel(null); - }; - }, [apiKey, userId, channelId, tokenProvider]); - - // Gracefully close/reopen the websocket on background/foreground. - // This preserves the client and channel state (no disconnect/reconnect). - const { isActive } = useAppLifecycle(); - const wasActiveRef = useRef(isActive); - useEffect(() => { - if (client) { - if (wasActiveRef.current && !isActive) { - void client.closeConnection(); - } else if (!wasActiveRef.current && isActive) { - void client.openConnection(); - } - } - wasActiveRef.current = isActive; - }, [client, isActive]); - - // Bot presence tracking - const sandboxId = channelId.replace(/^default-/, ''); - const botUserId = `bot-${sandboxId}`; - const botOnline = useBotOnlineStatus(client, channel, botUserId); - - if (connectError) { - return ( - - - - ); - } - - if (!client || !channel) { - return ( - - - - - - ); - } - - return ( - - { - setHeaderHeight(e.nativeEvent.layout.height); - }} - > - - - - - {/* eslint-disable-next-line typescript-eslint/no-unsafe-assignment -- expo-image is API-compatible with RN Image */} - - - - - - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/notification-prompt.tsx b/apps/mobile/src/components/kiloclaw/notification-prompt.tsx deleted file mode 100644 index 0348ab22c9..0000000000 --- a/apps/mobile/src/components/kiloclaw/notification-prompt.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Bell } from 'lucide-react-native'; -import { useCallback, useEffect, useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; -import * as Notifications from 'expo-notifications'; -import * as SecureStore from 'expo-secure-store'; -import { useMutation } from '@tanstack/react-query'; -import { toast } from 'sonner-native'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; - -import { Button } from '@/components/ui/button'; -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { - getNotificationPermissionStatus, - getPlatform, - registerForPushNotifications, -} from '@/lib/notifications'; -import { NOTIFICATION_PROMPT_SEEN_KEY } from '@/lib/storage-keys'; -import { useTRPC } from '@/lib/trpc'; - -export function NotificationPrompt({ enabled }: { enabled: boolean }) { - const [visible, setVisible] = useState(false); - const colors = useThemeColors(); - const trpc = useTRPC(); - - const registerToken = useMutation( - trpc.user.registerPushToken.mutationOptions({ - onError: error => { - toast.error(error.message); - }, - }) - ); - - useEffect(() => { - if (!enabled) { - return; - } - - async function check() { - const seen = await SecureStore.getItemAsync(NOTIFICATION_PROMPT_SEEN_KEY); - if (seen) { - return; - } - - const status = await getNotificationPermissionStatus(); - if (status === 'granted') { - return; - } - - setVisible(true); - } - void check(); - }, [enabled]); - - const handleEnable = useCallback(async () => { - const currentStatus = await getNotificationPermissionStatus(); - - if (currentStatus === 'denied') { - Alert.alert( - 'Notifications Disabled', - 'To enable notifications, turn them on in your device settings.', - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Open Settings', onPress: () => void Linking.openSettings() }, - ] - ); - return; - } - - const result = await Notifications.requestPermissionsAsync(); - if (result.status !== Notifications.PermissionStatus.GRANTED) { - return; - } - - await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true'); - setVisible(false); - - const token = await registerForPushNotifications(); - if (token) { - registerToken.mutate( - { token, platform: getPlatform() }, - { - onSuccess: () => { - toast.success('Notifications enabled'); - }, - } - ); - } - }, [registerToken]); - - const handleDismiss = useCallback(async () => { - await SecureStore.setItemAsync(NOTIFICATION_PROMPT_SEEN_KEY, 'true'); - setVisible(false); - }, []); - - if (!visible) { - return null; - } - - return ( - - - - - Get notified when Kilo replies - - We'll send a push notification so you don't miss anything. - - - - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index 43916918e8..cb56a270f1 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -309,7 +309,7 @@ export function OnboardingFlow() { ]); const onOpenInstance = useCallback(() => { - // Dismiss the onboarding modal, then open the chat. `chat/[instance-id]` + // Dismiss the onboarding modal, then open the chat. `chat/[sandbox-id]` // is at the (app) layer, so it renders above the tab bar once the modal // closes. router.back(); diff --git a/apps/mobile/src/lib/badge-buckets.ts b/apps/mobile/src/lib/badge-buckets.ts index cb32814020..017be2aa0d 100644 --- a/apps/mobile/src/lib/badge-buckets.ts +++ b/apps/mobile/src/lib/badge-buckets.ts @@ -1 +1,2 @@ -export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const; +export const badgeBucketForInstance = (sandboxId: string): `kiloclaw:${string}` => + `kiloclaw:${sandboxId}`; diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts deleted file mode 100644 index 64b1b366a2..0000000000 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-latest-message.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries'; - -const STREAM_CHAT_API_BASE = 'https://chat.stream-io-api.com'; - -type LatestMessage = { - text: string; - isFromMe: boolean; - created_at: string; -}; - -type StreamChatCredentials = { - apiKey: string; - userId: string; - userToken: string; - channelId: string; -}; - -type ChannelQueryResponse = { - messages?: { - text?: string; - created_at?: string; - user?: { id?: string }; - }[]; -}; - -async function fetchLatestMessage(creds: StreamChatCredentials): Promise { - const res = await fetch( - `${STREAM_CHAT_API_BASE}/channels/messaging/${creds.channelId}/query?api_key=${creds.apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: creds.userToken, - }, - body: JSON.stringify({ - state: true, - messages: { limit: 1 }, - }), - } - ); - - if (!res.ok) { - if (res.status === 404) { - return null; - } - const body = await res.text().catch(() => '(unreadable)'); - throw new Error(`Stream Chat query failed (${res.status}): ${body}`); - } - - const payload = (await res.json()) as ChannelQueryResponse; - const message = payload.messages?.[0]; - if (!message?.created_at) { - return null; - } - - return { - text: message.text ?? '', - isFromMe: message.user?.id === creds.userId, - created_at: message.created_at, - }; -} - -/** - * Fetch the most recent message on the KiloClaw chat channel directly from - * Stream Chat, reusing the short-lived user credentials exposed by - * `useStreamChatCredentials`. No extra backend endpoint required. - */ -export function useKiloClawLatestMessage(organizationId?: string | null, enabled = true) { - const { data: creds } = useStreamChatCredentials(organizationId, enabled); - const queryEnabled = enabled && Boolean(creds); - return useQuery({ - queryKey: ['kiloclaw-latest-message', creds?.channelId ?? null], - queryFn: async () => { - if (!creds) { - return null; - } - const latest = await fetchLatestMessage(creds); - return latest; - }, - enabled: queryEnabled, - staleTime: 30_000, - refetchInterval: queryEnabled ? 60_000 : false, - }); -} diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts index 6ea2818ebd..2796de3bb6 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-queries.ts @@ -289,24 +289,6 @@ export function useKiloClawSecretCatalog(organizationId?: string | null) { return isOrg ? org : personal; } -export function useStreamChatCredentials(organizationId?: string | null, enabled = true) { - const trpc = useTRPC(); - const { isOrg, personalEnabled, orgEnabled, orgInput } = resolveContext(organizationId, enabled); - const personal = useQuery( - trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - enabled: personalEnabled, - staleTime: 5 * 60_000, - }) - ); - const org = useQuery( - trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions(orgInput, { - enabled: orgEnabled, - staleTime: 5 * 60_000, - }) - ); - return isOrg ? org : personal; -} - export function useKiloClawConfig(organizationId?: string | null) { const trpc = useTRPC(); const { isOrg, personalEnabled, orgEnabled, orgInput } = resolveContext(organizationId); diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index f88f95cabc..04b28d5db5 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { useTRPC } from '@/lib/trpc'; /** - * Fetches unread message counts for the current user and returns a Map keyed - * by badge bucket for O(1) lookup from dashboard cards. + * Fetches unread message counts for the current user and returns a Map keyed by + * instance badge bucket for O(1) lookup from dashboard cards. Conversation + * buckets are summed into their parent instance bucket. * * Freshness is driven by invalidations, not polling: * - Foreground chat push → invalidate (see `use-unread-counts-invalidation`). @@ -23,7 +25,10 @@ export function useUnreadCounts() { const byBadgeBucket = useMemo(() => { const map = new Map(); for (const row of query.data ?? []) { - map.set(row.badgeBucket, row.badgeCount); + const parts = row.badgeBucket.split(':'); + const aggregateBucket = + parts[0] === 'kiloclaw' && parts[1] ? badgeBucketForInstance(parts[1]) : row.badgeBucket; + map.set(aggregateBucket, (map.get(aggregateBucket) ?? 0) + row.badgeCount); } return map; }, [query.data]); diff --git a/apps/mobile/src/lib/last-active-instance.ts b/apps/mobile/src/lib/last-active-instance.ts index 05f9577d0f..8929829b44 100644 --- a/apps/mobile/src/lib/last-active-instance.ts +++ b/apps/mobile/src/lib/last-active-instance.ts @@ -12,8 +12,3 @@ export async function loadLastActiveInstance(): Promise { export function getLastActiveInstance(): string | null { return cached; } - -export function setLastActiveInstance(id: string): void { - cached = id; - void SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, id); -} diff --git a/apps/web/package.json b/apps/web/package.json index eb8afda600..ea1c8e31e3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -143,8 +143,6 @@ "remark-gfm": "^4.0.1", "server-only": "^0.0.1", "sonner": "^2.0.7", - "stream-chat": "^9.38.0", - "stream-chat-react": "^13.14.2", "stripe": "catalog:", "stytch": "^12.43.1", "tailwind-merge": "^3.5.0", diff --git a/apps/web/src/app/(app)/claw/claw-chat.css b/apps/web/src/app/(app)/claw/claw-chat.css deleted file mode 100644 index e51d77aeac..0000000000 --- a/apps/web/src/app/(app)/claw/claw-chat.css +++ /dev/null @@ -1,153 +0,0 @@ -@import 'stream-chat-react/dist/css/v2/index.css'; - -/* ── Stream Chat theme overrides ────────────────────────────────────────────── - Stream Chat CSS is imported into layer(base) so these unlayered overrides - always win per the CSS cascade (unlayered > layered). - Scoped to .claw-chat-wrapper to avoid leaking outside the ChatTab. */ -.claw-chat-wrapper { - font-family: inherit; - border-radius: var(--radius-lg); - border: 1px solid oklch(1 0 0 / 6%); - background: oklch(0.269 0 0 / 0.2); - overflow: hidden; -} - -.claw-chat-wrapper .str-chat, -.claw-chat-wrapper .str-chat-channel, -.claw-chat-wrapper .str-chat__container { - height: 100%; -} - -.claw-chat-wrapper .str-chat { - /* ── Global theme: colors ─────────────────────────────────────────────── */ - --str-chat__primary-color: oklch(0.546 0.245 262.881); - --str-chat__active-primary-color: oklch(0.488 0.243 264.376); - --str-chat__primary-color-low-emphasis: oklch(0.546 0.245 262.881 / 0.3); - --str-chat__primary-overlay-color: oklch(0.546 0.245 262.881 / 0.6); - --str-chat__on-primary-color: oklch(0.985 0 0); - - --str-chat__background-color: transparent; - --str-chat__secondary-background-color: transparent; - - --str-chat__primary-surface-color: oklch(0.546 0.245 262.881 / 0.15); - --str-chat__primary-surface-color-low-emphasis: oklch(0.546 0.245 262.881 / 0.08); - --str-chat__surface-color: oklch(0.269 0 0 / 0.4); - --str-chat__secondary-surface-color: oklch(0.269 0 0 / 0.3); - --str-chat__tertiary-surface-color: oklch(0.269 0 0 / 0.2); - - --str-chat__text-color: oklch(0.985 0 0); - --str-chat__text-low-emphasis-color: oklch(0.708 0 0); - --str-chat__disabled-color: oklch(0.708 0 0); - --str-chat__on-disabled-color: oklch(0.985 0 0); - - --str-chat__danger-color: oklch(0.704 0.191 22.216); - --str-chat__info-color: oklch(0.696 0.17 162.48); - --str-chat__unread-badge-color: oklch(0.704 0.191 22.216); - --str-chat__on-unread-badge-color: oklch(0.985 0 0); - --str-chat__message-highlight-color: oklch(0.332 0.06 83); - - --str-chat__overlay-color: oklch(0 0 0 / 0.7); - --str-chat__secondary-overlay-color: oklch(0 0 0 / 0.4); - --str-chat__secondary-overlay-text-color: oklch(0.985 0 0); - --str-chat__opaque-surface-background-color: oklch(0.985 0 0 / 0.85); - --str-chat__opaque-surface-text-color: oklch(0.145 0 0); - --str-chat__box-shadow-color: oklch(0 0 0 / 0.8); - - /* ── Global theme: typography ─────────────────────────────────────────── */ - /* Note: `inherit` cannot be used as --str-chat__font-family because it's - a CSS-wide keyword that invalidates `font` shorthand substitution. - We use Inter directly to match the Kilo UI, with a system fallback. */ - --str-chat__font-family: Inter, ui-sans-serif, system-ui, sans-serif; - --str-chat__caption-text: 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__caption-medium-text: 500 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__caption-strong-text: 700 0.6875rem/1.3 var(--str-chat__font-family); - --str-chat__body-text: 0.8125rem/1.4 var(--str-chat__font-family); - --str-chat__body-medium-text: 500 0.8125rem/1.4 var(--str-chat__font-family); - --str-chat__body2-text: 0.875rem/1.4 var(--str-chat__font-family); - --str-chat__body2-medium-text: 500 0.875rem/1.4 var(--str-chat__font-family); - --str-chat__subtitle-text: 0.875rem/1.3 var(--str-chat__font-family); - --str-chat__subtitle-medium-text: 500 0.875rem/1.3 var(--str-chat__font-family); - --str-chat__subtitle2-text: 1rem/1.2 var(--str-chat__font-family); - --str-chat__subtitle2-medium-text: 500 1rem/1.2 var(--str-chat__font-family); - --str-chat__headline-text: 1.125rem/1.2 var(--str-chat__font-family); - --str-chat__headline2-text: 1.25rem/1.2 var(--str-chat__font-family); - - /* ── Global theme: border radius ──────────────────────────────────────── */ - --str-chat__border-radius-xs: 6px; - --str-chat__border-radius-sm: 8px; - --str-chat__border-radius-md: 10px; - --str-chat__border-radius-lg: 14px; - --str-chat__border-radius-circle: 999px; - - /* ── Component: message bubbles (badge-style: transparent bg + border) ── */ - --str-chat__message-bubble-background-color: transparent; - --str-chat__message-bubble-color: oklch(0.708 0 0); - --str-chat__message-bubble-border-block-start: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-block-end: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-inline-start: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-inline-end: 1px solid oklch(1 0 0 / 10%); - --str-chat__message-bubble-border-radius: var(--str-chat__border-radius-md); - --str-chat__own-message-bubble-background-color: transparent; - --str-chat__own-message-bubble-color: oklch(0.708 0 0); - - /* ── Component: message input ─────────────────────────────────────────── */ - --str-chat__message-input-background-color: transparent; - --str-chat__message-input-color: oklch(0.985 0 0); - --str-chat__message-textarea-background-color: oklch(0.269 0 0 / 0.4); - --str-chat__message-textarea-border-block-start: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-block-end: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-inline-start: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-border-inline-end: 1px solid oklch(1 0 0 / 6%); - --str-chat__message-textarea-color: oklch(0.985 0 0); - - /* ── Component: message list ──────────────────────────────────────────── */ - --str-chat__message-list-background-color: transparent; - --str-chat__message-list-color: oklch(0.985 0 0); - - /* ── Component: channel header ────────────────────────────────────────── */ - --str-chat__channel-header-background-color: transparent; - - /* ── Component: date separator ────────────────────────────────────────── */ - --str-chat__date-separator-color: oklch(0.708 0 0); - --str-chat__date-separator-line-color: oklch(1 0 0 / 10%); - - /* ── Component: message actions ───────────────────────────────────────── */ - --str-chat__message-actions-box-background-color: oklch(0.269 0 0 / 0.9); - --str-chat__message-actions-box-color: oklch(0.985 0 0); - --str-chat__message-actions-box-box-shadow: 0 4px 12px oklch(0 0 0 / 0.4); -} - -/* Constrain send button icon to 20x20 */ -.claw-chat-wrapper .str-chat__send-button svg { - width: 20px; - height: 20px; -} - -/* Hide bot sender name (long ID strings) */ -.claw-chat-wrapper .str-chat__message-simple-name { - display: none; -} - -/* ── Thinking indicator ────────────────────────────────────────────────────── */ -.claw-thinking-message { - display: flex; - align-items: center; - padding: 8px 16px; -} - -.claw-thinking-text { - font-style: italic; - font: var(--str-chat__body-text); - color: oklch(0.708 0 0); - animation: claw-thinking-pulse 1.5s ease-in-out infinite; -} - -@keyframes claw-thinking-pulse { - 0%, - 100% { - opacity: 0.4; - } - 50% { - opacity: 1; - } -} diff --git a/apps/web/src/app/(app)/claw/components/ChatTab.tsx b/apps/web/src/app/(app)/claw/components/ChatTab.tsx index 7ef6930f89..ef29dbf966 100644 --- a/apps/web/src/app/(app)/claw/components/ChatTab.tsx +++ b/apps/web/src/app/(app)/claw/components/ChatTab.tsx @@ -1,227 +1,10 @@ 'use client'; - -import { createContext, use, useCallback, useEffect, useState } from 'react'; -import type { Channel as StreamChannel, Event } from 'stream-chat'; -import { useQueryClient } from '@tanstack/react-query'; -import { MessageSquare, RotateCw } from 'lucide-react'; -import { - Chat, - Channel, - Window, - MessageList, - MessageInput, - MessageSimple, - Thread, - useCreateChatClient, - useChatContext, - useChannelStateContext, - useMessageContext, -} from 'stream-chat-react'; -import { useClawStreamChatCredentials } from '../hooks/useClawHooks'; -import { useTRPC } from '@/lib/trpc/utils'; -import { useClawContext } from './ClawContext'; - -const BotUserIdContext = createContext(''); - -type ChatTabProps = { - /** Only fetch credentials and connect when true (tab is active + instance running). */ - enabled: boolean; -}; - -export function ChatTab({ enabled }: ChatTabProps) { - const { data: creds, isLoading, error } = useClawStreamChatCredentials(enabled); - - if (!enabled) { - return ; - } - - if (isLoading) { - return ; - } - - if (error) { - return ; - } - - if (!creds) { - return ( -
-
- -
-
-

Chat requires an upgrade

-

- This instance was provisioned before chat was enabled. Use the{' '} - - - Upgrade to Latest - {' '} - button above to activate real-time chat with your KiloClaw bot. -

-
-
- ); - } - - return ; -} - -// ─── Internal components ──────────────────────────────────────────────────── - -function StreamChatUI({ - apiKey, - userId, - channelId, -}: { - apiKey: string; - userId: string; - channelId: string; -}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const { organizationId } = useClawContext(); - - // Stable token provider that fetches a fresh short-lived token on every call. - // stream-chat-react calls this when the current token expires (via `exp` claim). - // Routes to the correct tRPC endpoint based on personal vs org context. - const tokenProvider = useCallback(async () => { - const opts = organizationId - ? trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId }, - { staleTime: 0 } - ) - : trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - staleTime: 0, - }); - const creds = await queryClient.fetchQuery(opts); - if (!creds?.userToken) { - throw new Error('Failed to fetch Stream Chat credentials'); - } - return creds.userToken; - }, [queryClient, trpc, organizationId]); - - const client = useCreateChatClient({ - apiKey, - tokenOrProvider: tokenProvider, - userData: { id: userId }, - }); - - const [channel, setChannel] = useState(); - - useEffect(() => { - if (!client) return; - const ch = client.channel('messaging', channelId); - let cancelled = false; - void (async () => { - await ch.watch({ presence: true }); - if (cancelled) return; - // Disable file uploads client-side by stripping the capability before - // Channel reads it. This hides the attachment button, disables drag- - // and-drop, and makes paste-to-upload a no-op — all three paths in - // stream-chat-react gate on channel.data.own_capabilities["upload-file"]. - if (ch.data?.own_capabilities) { - ch.data.own_capabilities = ch.data.own_capabilities.filter( - capability => capability !== 'upload-file' - ); - } - setChannel(ch); - })(); - return () => { - cancelled = true; - void ch.stopWatching(); - }; - }, [client, channelId]); - - // channelId is "default-{sandboxId}", bot user is "bot-{sandboxId}" - const sandboxId = channelId.replace(/^default-/, ''); - const botUserId = `bot-${sandboxId}`; - - if (!client || !channel) { - return ; - } - - return ( - -
- - - - - - - - - - -
-
- ); -} - -function ClawMessage() { - const botUserId = use(BotUserIdContext); - const { message } = useMessageContext(); - const isBotThinking = - message.user?.id === botUserId && !message.text?.trim() && !message.attachments?.length; - - if (isBotThinking) { - return ( -
- Thinking… -
- ); - } - - return ; -} - -function useBotOnlineStatus(botUserId: string): boolean { - const { client } = useChatContext(); - const { channel } = useChannelStateContext(); - - const getBotOnline = useCallback((): boolean => { - const member = channel.state.members[botUserId]; - return !!member?.user?.online; - }, [channel, botUserId]); - - const [online, setOnline] = useState(getBotOnline); - +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +export default function ChatTab({ sandboxId }: { sandboxId: string }) { + const router = useRouter(); useEffect(() => { - setOnline(getBotOnline()); - - const handlePresenceChange = (event: Event) => { - if (event.user?.id === botUserId) { - setOnline(!!event.user.online); - } - }; - - client.on('user.presence.changed', handlePresenceChange); - return () => { - client.off('user.presence.changed', handlePresenceChange); - }; - }, [client, botUserId, getBotOnline]); - - return online; -} - -function BotStatusBar({ botUserId }: { botUserId: string }) { - const online = useBotOnlineStatus(botUserId); - - return ( -
- - KiloClaw {online ? 'Online' : 'Offline'} -
- ); -} - -function ChatPlaceholder({ message, isError = false }: { message: string; isError?: boolean }) { - return ( -
- {message} -
- ); + router.replace(`/claw/kilo-chat?sandboxId=${sandboxId}`); + }, [router, sandboxId]); + return null; } diff --git a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx index 0483c52701..a9ad8bc409 100644 --- a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'; import { useKiloClawStatus } from '@/hooks/useKiloClaw'; import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; import { ClawContextProvider } from './ClawContext'; -import { ChatTab } from './ChatTab'; +import ChatTab from './ChatTab'; import { ClawConfigServiceBanner } from './ClawConfigServiceBanner'; import { BillingWrapper } from './billing/BillingWrapper'; import { SetPageTitle } from '@/components/SetPageTitle'; @@ -56,13 +56,12 @@ function ClawChatWithStatus({ organizationId }: { organizationId?: string }) { if (!status || status.status === null) return null; - const isRunning = status.status === 'running'; const chatContent = ( <> - + diff --git a/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts b/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts index cb336b77d2..a64f6de4bf 100644 --- a/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts +++ b/apps/web/src/app/(app)/claw/hooks/useClawHooks.ts @@ -418,30 +418,6 @@ export function useClawGoogleSetupCommand(enabled: boolean) { return organizationId ? org : personal; } -// Stream Chat - -export function useClawStreamChatCredentials(enabled: boolean) { - const trpc = useTRPC(); - const { organizationId } = useClawContext(); - - const personal = useQuery({ - ...trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - staleTime: 5 * 60_000, - }), - enabled: enabled && !organizationId, - }); - - const org = useQuery({ - ...trpc.organizations.kiloclaw.getStreamChatCredentials.queryOptions( - { organizationId: organizationId ?? '' }, - { staleTime: 5 * 60_000 } - ), - enabled: enabled && !!organizationId, - }); - - return organizationId ? org : personal; -} - // Kilo CLI Run export function useClawKiloCliRunStatus(runId: string | null) { diff --git a/apps/web/src/app/(app)/claw/layout.tsx b/apps/web/src/app/(app)/claw/layout.tsx index 6aa97f7666..ee24137ed7 100644 --- a/apps/web/src/app/(app)/claw/layout.tsx +++ b/apps/web/src/app/(app)/claw/layout.tsx @@ -2,7 +2,6 @@ import { getUserFromAuthOrRedirect } from '@/lib/user.server'; import { PylonWidget } from '@/components/pylon-widget'; import { PylonSupportButton } from '@/components/pylon-support-button'; import { PersonalInstancePresenceMount } from './components/PersonalInstancePresenceMount'; -import './claw-chat.css'; export default async function ClawLayout({ children }: { children: React.ReactNode }) { await getUserFromAuthOrRedirect(); diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx index cabace358d..81e90f5079 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx @@ -1,7 +1,6 @@ import { PylonSupportButton } from '@/components/pylon-support-button'; import { PylonWidget } from '@/components/pylon-widget'; import { OrgInstancePresenceMount } from './components/OrgInstancePresenceMount'; -import '@/app/(app)/claw/claw-chat.css'; export default function OrgClawLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/web/src/app/api/kiloclaw/chat-credentials/route.ts b/apps/web/src/app/api/kiloclaw/chat-credentials/route.ts deleted file mode 100644 index a52a5a171f..0000000000 --- a/apps/web/src/app/api/kiloclaw/chat-credentials/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NextResponse } from 'next/server'; -import { TRPCError } from '@trpc/server'; -import { getUserFromAuth } from '@/lib/user.server'; -import { KiloClawUserClient } from '@/lib/kiloclaw/kiloclaw-user-client'; -import { KiloClawApiError } from '@/lib/kiloclaw/kiloclaw-internal-client'; -import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; -import { requireKiloClawAccessAtInstance } from '@/lib/kiloclaw/access-gate'; -import { - getActiveInstance, - getActiveOrgInstance, - workerInstanceId, -} from '@/lib/kiloclaw/instance-registry'; - -export async function GET() { - const { user, authFailedResponse, organizationId } = await getUserFromAuth({ - adminOnly: false, - }); - if (authFailedResponse) return authFailedResponse; - - // Personal-only billing gate — org access is gated at org membership level - // (validated by getUserFromAuth). Matches tRPC org router's - // getStreamChatCredentials which uses organizationMemberProcedure (no billing gate). - if (!organizationId) { - const instance = await getActiveInstance(user.id); - if (!instance) { - return NextResponse.json({ error: 'No active KiloClaw instance found' }, { status: 404 }); - } - - try { - await requireKiloClawAccessAtInstance(user.id, instance.id); - } catch (err) { - if (err instanceof TRPCError && err.code === 'NOT_FOUND') { - return NextResponse.json({ error: err.message }, { status: 404 }); - } - if (err instanceof TRPCError && err.code === 'FORBIDDEN') { - return NextResponse.json({ error: err.message }, { status: 403 }); - } - throw err; - } - } - - try { - const instance = organizationId - ? await getActiveOrgInstance(user.id, organizationId) - : await getActiveInstance(user.id); - - // No org instance → 404. Without this guard workerInstanceId(null) - // → undefined → the worker queries the personal DO, leaking personal - // credentials into the org context. - if (organizationId && !instance) { - return NextResponse.json( - { error: 'No active instance for this organization' }, - { status: 404 } - ); - } - - const token = generateApiToken(user, undefined, { - expiresIn: TOKEN_EXPIRY.fiveMinutes, - }); - const client = new KiloClawUserClient(token); - const creds = await client.getChatCredentials({ - userId: user.id, - instanceId: workerInstanceId(instance), - }); - return NextResponse.json(creds); - } catch (err) { - const status = err instanceof KiloClawApiError ? err.statusCode : 502; - console.error('[api/kiloclaw/chat-credentials] error:', err); - return NextResponse.json({ error: 'KiloClaw request failed' }, { status }); - } -} diff --git a/apps/web/src/hooks/useKiloClaw.ts b/apps/web/src/hooks/useKiloClaw.ts index 18ee66dcd0..d9c162c3ca 100644 --- a/apps/web/src/hooks/useKiloClaw.ts +++ b/apps/web/src/hooks/useKiloClaw.ts @@ -61,16 +61,6 @@ export function useRefreshDevicePairing() { }; } -export function useStreamChatCredentials(enabled: boolean) { - const trpc = useTRPC(); - return useQuery( - trpc.kiloclaw.getStreamChatCredentials.queryOptions(undefined, { - enabled, - staleTime: 5 * 60_000, // credentials don't change; avoid redundant refetches - }) - ); -} - export function useKiloClawGatewayStatus(enabled: boolean) { const trpc = useTRPC(); return useQuery( diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts index 942948a41e..368a2bee61 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -300,37 +300,6 @@ export class KiloClawInternalClient { }); } - async getStreamChatCredentials( - userId: string, - instanceId?: string - ): Promise<{ - apiKey: string; - userId: string; - userToken: string; - channelId: string; - } | null> { - const params = new URLSearchParams({ userId }); - if (instanceId) params.set('instanceId', instanceId); - return this.request(`/api/platform/stream-chat-credentials?${params.toString()}`, undefined, { - userId, - }); - } - - async sendChatMessage( - userId: string, - message: string, - instanceId?: string - ): Promise<{ success: boolean; channelId: string }> { - return this.request( - '/api/platform/send-chat-message', - { - method: 'POST', - body: JSON.stringify({ userId, message, instanceId }), - }, - { userId } - ); - } - async getMorningBriefingStatus( userId: string, instanceId?: string diff --git a/apps/web/src/lib/kiloclaw/kiloclaw-user-client.ts b/apps/web/src/lib/kiloclaw/kiloclaw-user-client.ts index 137d33e410..92a511da65 100644 --- a/apps/web/src/lib/kiloclaw/kiloclaw-user-client.ts +++ b/apps/web/src/lib/kiloclaw/kiloclaw-user-client.ts @@ -2,12 +2,7 @@ import 'server-only'; import { KILOCLAW_API_URL } from '@/lib/config.server'; import { KiloClawApiError } from './kiloclaw-internal-client'; -import type { - UserConfigResponse, - PlatformStatusResponse, - RestartMachineResponse, - ChatCredentials, -} from './types'; +import type { UserConfigResponse, PlatformStatusResponse, RestartMachineResponse } from './types'; type RequestContext = { userId: string; instanceId?: string }; @@ -65,10 +60,6 @@ export class KiloClawUserClient { return this.request('/api/kiloclaw/status', undefined, ctx); } - async getChatCredentials(ctx?: RequestContext): Promise { - return this.request('/api/kiloclaw/chat-credentials', undefined, ctx); - } - async restartMachine( options?: { imageTag?: string }, ctx?: RequestContext diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts index fe0a6fb343..27192b105b 100644 --- a/apps/web/src/lib/kiloclaw/types.ts +++ b/apps/web/src/lib/kiloclaw/types.ts @@ -584,14 +584,6 @@ export type UpdateProviderRolloutResponse = { availability: ProviderRolloutAvailability; }; -/** Stream Chat credentials for a user's KiloClaw channel */ -export type ChatCredentials = { - apiKey: string; - userId: string; - userToken: string; - channelId: string; -} | null; - /** Combined status returned by tRPC getStatus */ export type KiloClawDashboardStatus = PlatformStatusResponse & { /** Worker base URL for constructing the "Open" link. Falls back to claw.kilo.ai. */ diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 2db8bc5bdb..ae05c81cbd 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -2413,78 +2413,6 @@ export const kiloclawRouter = createTRPCRouter({ return instance ? { instanceId: instance.id } : null; }), - getStreamChatCredentials: clawAccessProcedure.query(async ({ ctx }) => { - const instance = await getActiveInstance(ctx.user.id); - const client = new KiloClawInternalClient(); - return client.getStreamChatCredentials(ctx.user.id, workerInstanceId(instance)); - }), - - sendChatMessage: clawAccessProcedure - .input( - z.object({ - instanceId: z.string().uuid().optional(), - message: z.string().min(1).max(32_000), - }) - ) - .mutation(async ({ ctx, input }) => { - if (input.instanceId) { - // Explicit instanceId: verify ownership and non-destroyed - const [row] = await db - .select({ id: kiloclaw_instances.id }) - .from(kiloclaw_instances) - .where( - and( - eq(kiloclaw_instances.id, input.instanceId), - eq(kiloclaw_instances.user_id, ctx.user.id), - isNull(kiloclaw_instances.destroyed_at) - ) - ) - .limit(1); - if (!row) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No active KiloClaw instance found', - }); - } - } else { - // No instanceId: verify the user has any active instance - const instance = await getActiveInstance(ctx.user.id); - if (!instance) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'No active KiloClaw instance found', - }); - } - } - - const client = new KiloClawInternalClient(); - try { - return await client.sendChatMessage(ctx.user.id, input.message, input.instanceId); - } catch (err) { - if (err instanceof KiloClawApiError) { - const { message } = getKiloClawApiErrorPayload(err); - const code = - err.statusCode === 400 - ? 'BAD_REQUEST' - : err.statusCode === 403 - ? 'FORBIDDEN' - : err.statusCode === 404 - ? 'NOT_FOUND' - : err.statusCode === 503 - ? 'PRECONDITION_FAILED' - : 'INTERNAL_SERVER_ERROR'; - throw new TRPCError({ - code, - message: message ?? 'Failed to send chat message', - }); - } - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to send chat message', - }); - } - }), - getMorningBriefingStatus: clawAccessProcedure.query(async ({ ctx }) => { const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawInternalClient(); diff --git a/apps/web/src/routers/kiloclaw-send-chat-message.test.ts b/apps/web/src/routers/kiloclaw-send-chat-message.test.ts deleted file mode 100644 index a7fdc7b82e..0000000000 --- a/apps/web/src/routers/kiloclaw-send-chat-message.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it, beforeAll, beforeEach, jest } from '@jest/globals'; -import { db, cleanupDbForTest } from '@/lib/drizzle'; -import { kiloclaw_instances, kiloclaw_subscriptions } from '@kilocode/db/schema'; -import { insertTestUser } from '@/tests/helpers/user.helper'; -import type { User } from '@kilocode/db/schema'; - -// ── Mocks ────────────────────────────────────────────────────────────────── - -// Mock KiloClawInternalClient to avoid real HTTP calls -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockSendChatMessage: jest.Mock = jest.fn(); -jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => { - // Import the real KiloClawApiError so tests can throw it - const actual: Record = jest.requireActual( - '@/lib/kiloclaw/kiloclaw-internal-client' - ); - return { - KiloClawInternalClient: jest.fn().mockImplementation(() => ({ - sendChatMessage: mockSendChatMessage, - })), - KiloClawApiError: actual.KiloClawApiError, - }; -}); - -jest.mock('next/headers', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fn = jest.fn as (...args: any[]) => jest.Mock; - return { - cookies: fn().mockResolvedValue({ get: fn() }), - headers: fn().mockReturnValue(new Map()), - }; -}); - -// ── Dynamic imports (after mocks) ────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let createCallerForUser: (userId: string) => Promise; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let KiloClawApiError: any; - -beforeAll(async () => { - const mod = await import('@/routers/test-utils'); - createCallerForUser = mod.createCallerForUser; - const clientMod = await import('@/lib/kiloclaw/kiloclaw-internal-client'); - KiloClawApiError = clientMod.KiloClawApiError; -}); - -// ── Helpers ──────────────────────────────────────────────────────────────── - -let user: User; -let otherUser: User; - -beforeEach(async () => { - await cleanupDbForTest(); - mockSendChatMessage.mockReset(); - - user = await insertTestUser({ - google_user_email: `sendchat-test-${Math.random()}@example.com`, - }); - otherUser = await insertTestUser({ - google_user_email: `sendchat-other-${Math.random()}@example.com`, - }); -}); - -async function createActiveInstance(userId: string): Promise { - const [row] = await db - .insert(kiloclaw_instances) - .values({ - user_id: userId, - sandbox_id: `sandbox-${userId.slice(0, 8)}`, - }) - .returning(); - return row.id; -} - -async function createDestroyedInstance(userId: string): Promise { - const [row] = await db - .insert(kiloclaw_instances) - .values({ - user_id: userId, - sandbox_id: `sandbox-destroyed-${userId.slice(0, 8)}`, - destroyed_at: new Date().toISOString(), - }) - .returning(); - return row.id; -} - -async function grantKiloClawAccess(userId: string, instanceId: string): Promise { - await db.insert(kiloclaw_subscriptions).values({ - user_id: userId, - instance_id: instanceId, - plan: 'standard', - status: 'active', - stripe_subscription_id: `sub_test_${crypto.randomUUID()}`, - }); -} - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('kiloclaw.sendChatMessage', () => { - describe('billing gate (clawAccessProcedure)', () => { - it('rejects users without KiloClaw access', async () => { - await createActiveInstance(user.id); - const caller = await createCallerForUser(user.id); - - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'FORBIDDEN', - }); - }); - - it('allows users with active subscription', async () => { - const instanceId = await createActiveInstance(user.id); - await grantKiloClawAccess(user.id, instanceId); - mockSendChatMessage.mockResolvedValue({ success: true, channelId: 'chan-1' }); - - const caller = await createCallerForUser(user.id); - const result = await caller.kiloclaw.sendChatMessage({ message: 'test' }); - expect(result.success).toBe(true); - }); - }); - - describe('ownership validation', () => { - it('rejects when user has access but no active instance (no instanceId)', async () => { - const destroyedInstanceId = await createDestroyedInstance(user.id); - await grantKiloClawAccess(user.id, destroyedInstanceId); - const caller = await createCallerForUser(user.id); - - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'NOT_FOUND', - message: 'No active KiloClaw instance found', - }); - }); - - it('rejects when instanceId belongs to another user', async () => { - const accessInstanceId = await createActiveInstance(user.id); - await grantKiloClawAccess(user.id, accessInstanceId); - const otherInstanceId = await createActiveInstance(otherUser.id); - - const caller = await createCallerForUser(user.id); - await expect( - caller.kiloclaw.sendChatMessage({ instanceId: otherInstanceId, message: 'test' }) - ).rejects.toMatchObject({ - code: 'NOT_FOUND', - message: 'No active KiloClaw instance found', - }); - }); - - it('rejects when instanceId points to a destroyed instance', async () => { - const accessInstanceId = await createActiveInstance(user.id); - await grantKiloClawAccess(user.id, accessInstanceId); - const destroyedId = await createDestroyedInstance(user.id); - - const caller = await createCallerForUser(user.id); - await expect( - caller.kiloclaw.sendChatMessage({ instanceId: destroyedId, message: 'test' }) - ).rejects.toMatchObject({ - code: 'NOT_FOUND', - message: 'No active KiloClaw instance found', - }); - }); - - it('allows sending to own active instance by instanceId', async () => { - const instanceId = await createActiveInstance(user.id); - await grantKiloClawAccess(user.id, instanceId); - mockSendChatMessage.mockResolvedValue({ success: true, channelId: 'chan-1' }); - - const caller = await createCallerForUser(user.id); - const result = await caller.kiloclaw.sendChatMessage({ - instanceId, - message: 'hello', - }); - expect(result.success).toBe(true); - expect(mockSendChatMessage).toHaveBeenCalledWith(user.id, 'hello', instanceId); - }); - }); - - describe('error translation (KiloClawApiError → TRPCError)', () => { - beforeEach(async () => { - const instanceId = await createActiveInstance(user.id); - await grantKiloClawAccess(user.id, instanceId); - }); - - it('maps worker 400 to tRPC BAD_REQUEST', async () => { - mockSendChatMessage.mockRejectedValue(new KiloClawApiError(400, '{"error":"bad input"}')); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'BAD_REQUEST', - message: 'bad input', - }); - }); - - it('maps worker 403 to tRPC FORBIDDEN', async () => { - mockSendChatMessage.mockRejectedValue(new KiloClawApiError(403, '{"error":"forbidden"}')); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'FORBIDDEN', - message: 'forbidden', - }); - }); - - it('maps worker 404 to tRPC NOT_FOUND', async () => { - mockSendChatMessage.mockRejectedValue( - new KiloClawApiError(404, '{"error":"Stream Chat is not set up for this instance"}') - ); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'NOT_FOUND', - message: 'Stream Chat is not set up for this instance', - }); - }); - - it('maps worker 503 to tRPC PRECONDITION_FAILED', async () => { - mockSendChatMessage.mockRejectedValue( - new KiloClawApiError(503, '{"error":"Stream Chat is not configured"}') - ); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'PRECONDITION_FAILED', - message: 'Stream Chat is not configured', - }); - }); - - it('maps unknown worker errors to tRPC INTERNAL_SERVER_ERROR', async () => { - mockSendChatMessage.mockRejectedValue(new KiloClawApiError(502, '')); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to send chat message', - }); - }); - - it('maps non-KiloClawApiError to tRPC INTERNAL_SERVER_ERROR', async () => { - mockSendChatMessage.mockRejectedValue(new Error('network error')); - - const caller = await createCallerForUser(user.id); - await expect(caller.kiloclaw.sendChatMessage({ message: 'test' })).rejects.toMatchObject({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to send chat message', - }); - }); - }); -}); diff --git a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts index b2fbe70d03..831a8d7734 100644 --- a/apps/web/src/routers/organizations/organization-kiloclaw-router.ts +++ b/apps/web/src/routers/organizations/organization-kiloclaw-router.ts @@ -1130,39 +1130,6 @@ export const organizationKiloclawRouter = createTRPCRouter({ return { success: true }; }), - // ── Stream Chat ──────────────────────────────────────────────── - - getStreamChatCredentials: organizationMemberProcedure.query(async ({ ctx, input }) => { - const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId); - const client = new KiloClawInternalClient(); - return client.getStreamChatCredentials(ctx.user.id, workerInstanceId(instance)); - }), - - sendChatMessage: organizationMemberMutationProcedure - .input(z.object({ organizationId: z.uuid(), message: z.string().min(1).max(32_000) })) - .mutation(async ({ ctx, input }) => { - const instance = await requireOrgInstance(ctx.user.id, input.organizationId); - const client = new KiloClawInternalClient(); - try { - return await client.sendChatMessage(ctx.user.id, input.message, instance.id); - } catch (err) { - if (err instanceof KiloClawApiError) { - const { message } = getKiloClawApiErrorPayload(err); - const code = - err.statusCode === 404 - ? 'NOT_FOUND' - : err.statusCode === 503 - ? 'PRECONDITION_FAILED' - : 'INTERNAL_SERVER_ERROR'; - throw new TRPCError({ - code, - message: message ?? 'Failed to send chat message', - }); - } - throw err; - } - }), - getMorningBriefingStatus: organizationMemberProcedure.query(async ({ ctx, input }) => { const instance = await requireOrgInstance(ctx.user.id, input.organizationId); const client = new KiloClawInternalClient(); diff --git a/package.json b/package.json index fa2dd4d7cd..a8a686518e 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,6 @@ }, "patchedDependencies": { "@storybook/nextjs@9.1.20": "patches/@storybook__nextjs@9.1.20.patch", - "@gorhom/bottom-sheet@5.1.8": "patches/@gorhom__bottom-sheet@5.1.8.patch", - "stream-chat-react-native-core": "patches/stream-chat-react-native-core.patch", "expo-server-sdk": "patches/expo-server-sdk.patch" }, "onlyBuiltDependencies": [ @@ -77,7 +75,6 @@ "esbuild", "libpq", "protobufjs", - "stream-chat-react-native-core", "workerd" ] } diff --git a/patches/@gorhom__bottom-sheet@5.1.8.patch b/patches/@gorhom__bottom-sheet@5.1.8.patch deleted file mode 100644 index c73a467bdc..0000000000 --- a/patches/@gorhom__bottom-sheet@5.1.8.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/lib/commonjs/hooks/useBoundingClientRect.js b/lib/commonjs/hooks/useBoundingClientRect.js -index b4a90b76ee55bf2cad9cf461017621b1ddab0fe1..3140ff9d1d3bd9ae28fc5124ac642af0eda74ea7 100644 ---- a/lib/commonjs/hooks/useBoundingClientRect.js -+++ b/lib/commonjs/hooks/useBoundingClientRect.js -@@ -45,19 +45,25 @@ function useBoundingClientRect(ref, handler) { - return; - } - -+ if ( - // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -- if (ref.current.unstable_getBoundingClientRect !== null) { -+ ref.current.unstable_getBoundingClientRect !== null && -+ // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -+ typeof ref.current.unstable_getBoundingClientRect === 'function') { - // @ts-ignore https://github.com/facebook/react/commit/53b1f69ba -- const layout = ref.current.unstable_getBoundingClientRect(); -+ var layout = ref.current.unstable_getBoundingClientRect(); - handler(layout); - return; - } - -+ if ( - // @ts-ignore once it `unstable_getBoundingClientRect` gets stable 🤞. -- if (ref.current.getBoundingClientRect !== null) { -+ ref.current.getBoundingClientRect !== null && -+ // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. -+ typeof ref.current.getBoundingClientRect === 'function') { - // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. -- const layout = ref.current.getBoundingClientRect(); -- handler(layout); -+ var _layout = ref.current.getBoundingClientRect(); -+ handler(_layout); - } - }); - } -diff --git a/lib/module/hooks/useBoundingClientRect.js b/lib/module/hooks/useBoundingClientRect.js -index a723aede9d4cfbb46f5985c531e0dae8f517aba8..2da7edb539836fef15f9ed29d38cfe4608afd121 100644 ---- a/lib/module/hooks/useBoundingClientRect.js -+++ b/lib/module/hooks/useBoundingClientRect.js -@@ -41,16 +41,22 @@ export function useBoundingClientRect(ref, handler) { - return; - } - -+ if ( - // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -- if (ref.current.unstable_getBoundingClientRect !== null) { -+ ref.current.unstable_getBoundingClientRect !== null && -+ // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -+ typeof ref.current.unstable_getBoundingClientRect === 'function') { - // @ts-ignore https://github.com/facebook/react/commit/53b1f69ba - const layout = ref.current.unstable_getBoundingClientRect(); - handler(layout); - return; - } - -+ if ( - // @ts-ignore once it `unstable_getBoundingClientRect` gets stable 🤞. -- if (ref.current.getBoundingClientRect !== null) { -+ ref.current.getBoundingClientRect !== null && -+ // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. -+ typeof ref.current.getBoundingClientRect === 'function') { - // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. - const layout = ref.current.getBoundingClientRect(); - handler(layout); -diff --git a/src/hooks/useBoundingClientRect.ts b/src/hooks/useBoundingClientRect.ts -index cc85c8ced2de8ec514360368ed20af733f8f9aec..9abe8294d6004be4500871e46a3621a9e5b9d93b 100644 ---- a/src/hooks/useBoundingClientRect.ts -+++ b/src/hooks/useBoundingClientRect.ts -@@ -55,16 +55,24 @@ export function useBoundingClientRect( - return; - } - -- // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -- if (ref.current.unstable_getBoundingClientRect !== null) { -+ if ( -+ // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -+ ref.current.unstable_getBoundingClientRect !== null && -+ // @ts-ignore 👉 https://github.com/facebook/react/commit/53b1f69ba -+ typeof ref.current.unstable_getBoundingClientRect === 'function' -+ ) { - // @ts-ignore https://github.com/facebook/react/commit/53b1f69ba - const layout = ref.current.unstable_getBoundingClientRect(); - handler(layout); - return; - } - -- // @ts-ignore once it `unstable_getBoundingClientRect` gets stable 🤞. -- if (ref.current.getBoundingClientRect !== null) { -+ if ( -+ // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. -+ ref.current.getBoundingClientRect !== null && -+ // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. -+ typeof ref.current.getBoundingClientRect === 'function' -+ ) { - // @ts-ignore once it `unstable_getBoundingClientRect` gets stable. - const layout = ref.current.getBoundingClientRect(); - handler(layout); diff --git a/patches/stream-chat-react-native-core.patch b/patches/stream-chat-react-native-core.patch deleted file mode 100644 index 789cb69c31..0000000000 --- a/patches/stream-chat-react-native-core.patch +++ /dev/null @@ -1,65 +0,0 @@ -diff --git a/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx b/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx -index 61e82d37fdfa6e7d5199330549f35e1b220d867f..69abf2e32aa96b995f11f1484c5e33ac03cee6a9 100644 ---- a/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx -+++ b/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx -@@ -1,6 +1,7 @@ - import React from 'react'; - --import { Alert, ImageBackground, StyleSheet, Text, View } from 'react-native'; -+import { Alert, StyleSheet, Text, View } from 'react-native'; -+import { Image as ExpoImage } from 'expo-image'; - - import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat'; - -@@ -67,8 +68,7 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { - - return ( - -- { - image, - ]} - > -+ - {selected && ( - - -@@ -91,7 +92,7 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { - - ) : null} - -- -+
- - ); - }; -@@ -138,8 +139,7 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { - - return ( - -- { - image, - ]} - > -+ - {selected && ( - - - - )} -- -+
- - ); - }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fde8db827f..5fd5cf94ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,9 +51,6 @@ catalogs: p-limit: specifier: ^7.3.0 version: 7.3.0 - stream-chat: - specifier: ^9.38.0 - version: 9.38.0 stripe: specifier: ^19.3.0 version: 19.3.0 @@ -90,18 +87,12 @@ overrides: axios: '>=1.15.0 <2' patchedDependencies: - '@gorhom/bottom-sheet@5.1.8': - hash: c5eaae9a28f5662f32d66e0129609680309eddc44720d6a2b1e02bbc2b5dd11f - path: patches/@gorhom__bottom-sheet@5.1.8.patch '@storybook/nextjs@9.1.20': hash: e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1 path: patches/@storybook__nextjs@9.1.20.patch expo-server-sdk: hash: 7850520582b5b394397b35d1ea195192fe78589d8a6a748fe15177b818c4ed0b path: patches/expo-server-sdk.patch - stream-chat-react-native-core: - hash: 6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0 - path: patches/stream-chat-react-native-core.patch importers: @@ -239,9 +230,6 @@ importers: expo-image: specifier: ~55.0.8 version: 55.0.8(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - expo-image-manipulator: - specifier: ~55.0.14 - version: 55.0.14(expo@55.0.12) expo-image-picker: specifier: ~55.0.17 version: 55.0.17(expo@55.0.12) @@ -326,21 +314,12 @@ importers: sonner-native: specifier: ^0.23.1 version: 0.23.1(53175ba88151f39b99a3b76a61c65c1d) - stream-chat: - specifier: 'catalog:' - version: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - stream-chat-expo: - specifier: ^8.13.7 - version: 8.13.7(e673e8bffb1896cc06607271df6a38dc) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 tailwindcss: specifier: ^4.2.2 version: 4.2.2 - zod: - specifier: 'catalog:' - version: 4.3.6 devDependencies: '@sentry/cli': specifier: ^3.3.4 @@ -817,12 +796,6 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - stream-chat: - specifier: ^9.38.0 - version: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - stream-chat-react: - specifier: ^13.14.2 - version: 13.14.2(@emoji-mart/data@1.2.1)(@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.4))(@types/react@19.2.14)(emoji-mart@5.6.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(stream-chat@9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3) stripe: specifier: 'catalog:' version: 19.3.0(@types/node@24.12.0) @@ -1604,7 +1577,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -3259,9 +3232,6 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@braintree/sanitize-url@6.0.4': - resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} - '@chat-adapter/shared@4.20.1': resolution: {integrity: sha512-UawGmT7O+3vxvaU9f+lc0PVQKU+TvE0PUxa0zL43qH1rqGkosngtT3cOOhW6JOx+rxt3jox2a99xr8hnJPkshA==} @@ -3930,36 +3900,9 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.27.19': - resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@gorhom/bottom-sheet@5.1.8': - resolution: {integrity: sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A==} - peerDependencies: - '@types/react': '*' - '@types/react-native': '*' - react: '*' - react-native: '*' - react-native-gesture-handler: '>=2.16.1' - react-native-reanimated: '>=3.16.0 || >=4.0.0-' - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-native': - optional: true - - '@gorhom/portal@1.0.14': - resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==} - peerDependencies: - react: '*' - react-native: '*' - '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -6102,30 +6045,6 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@react-aria/focus@3.21.5': - resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/interactions@3.27.1': - resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/ssr@3.9.10': - resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} - engines: {node: '>= 12'} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-aria/utils@3.33.1': - resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-native-community/netinfo@11.5.2': resolution: {integrity: sha512-/g0m65BtX9HU+bPiCH2517bOHpEIUsGrWFXDzi1a5nNKn5KujQgm04WhL7/OSXWKHyrT8VVtUoJA0XKRxueBpQ==} peerDependencies: @@ -6238,19 +6157,6 @@ packages: '@react-navigation/routers@7.5.3': resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} - '@react-stately/flags@3.1.2': - resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} - - '@react-stately/utils@3.11.0': - resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-types/shared@3.33.1': - resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@redis/bloom@5.11.0': resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} engines: {node: '>= 18'} @@ -7333,14 +7239,6 @@ packages: peerDependencies: storybook: ^9.1.17 - '@stream-io/escape-string-regexp@5.0.1': - resolution: {integrity: sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ==} - engines: {node: '>=12'} - - '@stream-io/transliterate@1.5.5': - resolution: {integrity: sha512-r6Qp0HylAZhHNWHxU1nGfRI2Dtkbs1iqLCnOp1bvKhv8yj0/sEUigN0dk0LGPbE4I7zDO3tppyd7PaTPBvvJkg==} - engines: {node: '>=12'} - '@streamparser/json@0.0.22': resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==} @@ -8037,15 +7935,6 @@ packages: '@opentelemetry/sdk-metrics': '>=2.0.0 <3.0.0' '@opentelemetry/sdk-trace-base': '>=2.0.0 <3.0.0' - '@virtuoso.dev/react-urx@0.2.13': - resolution: {integrity: sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16' - - '@virtuoso.dev/urx@0.2.13': - resolution: {integrity: sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw==} - '@vitest/coverage-v8@4.1.0': resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} peerDependencies: @@ -8434,10 +8323,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - attr-accept@2.2.5: - resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} - engines: {node: '>=4'} - auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -9280,9 +9165,6 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -9703,9 +9585,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - emojis-list@3.0.0: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} @@ -10077,11 +9956,6 @@ packages: peerDependencies: expo: '*' - expo-image-manipulator@55.0.14: - resolution: {integrity: sha512-j46l8ok7lWrDvgYaIJTjrSg7zBuDrGIbR7TFI6VnI/IfFUi/CGqMfw1Ks+2wzXB1Vcs+LLH8OLv+WR1y+/zVKg==} - peerDependencies: - expo: '*' - expo-image-picker@55.0.17: resolution: {integrity: sha512-oCayiw6ZMKDnUGVPFhQ1j0Cg0ZvzSDWwuVm0QSX+AkdqBuRv/n3SB3ZTVW2M+lR6zU/aTtVTduqlNnVyv4CrhA==} peerDependencies: @@ -10346,10 +10220,6 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - file-selector@2.1.2: - resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} - engines: {node: '>= 12'} - filesize@10.1.6: resolution: {integrity: sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==} engines: {node: '>= 10.4.0'} @@ -10415,9 +10285,6 @@ packages: resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} engines: {node: '>=18'} - fix-webm-duration@1.0.6: - resolution: {integrity: sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==} - flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -10682,12 +10549,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hast-util-find-and-replace@5.0.1: - resolution: {integrity: sha512-S12fTskO3Hf2IGCBWXs1UcXT8GEJ3jmvmPZJctkRwfl3a8jnGi8aFYT8kd2zcEH+VE0qcGgKF0ewt5BPAsfIhw==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -10801,14 +10662,6 @@ packages: engines: {node: '>=18'} hasBin: true - i18next@25.10.4: - resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==} - peerDependencies: - typescript: ^5 - peerDependenciesMeta: - typescript: - optional: true - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -10873,9 +10726,6 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} - inherits@2.0.3: - resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -10910,9 +10760,6 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} - intl-pluralrules@2.0.1: - resolution: {integrity: sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==} - invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -11088,11 +10935,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -11654,12 +11496,6 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - linkifyjs@4.3.2: - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} - - load-script@1.0.0: - resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} - loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -11690,9 +11526,6 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.deburr@4.1.0: - resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} - lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} @@ -11720,9 +11553,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.mergewith@4.6.2: - resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -11732,9 +11562,6 @@ packages: lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} - lodash.uniqby@4.7.0: - resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -12132,11 +11959,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - mime@4.1.0: - resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} - engines: {node: '>=16'} - hasBin: true - mimic-fn@1.2.0: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} @@ -12693,9 +12515,6 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - path@0.12.7: - resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -12958,9 +12777,6 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -13066,12 +12882,6 @@ packages: peerDependencies: react: ^19.2.4 - react-dropzone@14.4.1: - resolution: {integrity: sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==} - engines: {node: '>= 10.13'} - peerDependencies: - react: '>= 16.8 || 18.0.0' - react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -13081,11 +12891,6 @@ packages: peerDependencies: react: '>=17.0.0' - react-image-gallery@1.2.12: - resolution: {integrity: sha512-JIh85lh0Av/yewseGJb/ycg00Y/weQiZEC/BQueC2Z5jnYILGB6mkxnrOevNhsM2NdZJpvcDekCluhy6uzEoTA==} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -13101,12 +12906,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-markdown@9.1.0: - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - react-native-appsflyer@6.17.9: resolution: {integrity: sha512-oEddwSsVL8D3ki8ayWZV34GyORAxvL1BXq3mL1xB8Hdfg+xxLyjAXSvWbj0t3E3NJ2KrgLRf/hbTlsPltfo/Uw==} @@ -13136,12 +12935,6 @@ packages: react: '*' react-native: '*' - react-native-lightbox@0.7.0: - resolution: {integrity: sha512-HS3T4WlCd0Gb3us2d6Jse5m6KjNhngnKm35Wapq30WtQa9s+/VMmtuktbGPGaWtswcDyOj6qByeJBw9W80iPCA==} - - react-native-markdown-package@1.8.2: - resolution: {integrity: sha512-F3z/p0XfY6Nu9NlXQx1pYcPdz7Y37NRcAKTN+yb9nwRi8BW75mdc3uaBrM13PDVUlL0hbfTL7FuoAdSbsyB5vg==} - react-native-marked@8.0.1: resolution: {integrity: sha512-lUAM/w9AxY54PP2BKHnDiarJ1+8s9R8HzkjnIrCkZO1fOSAdFn0XsAVMM4fGOP8QM4wIZcJhhvaW/5K7oGy5aQ==} engines: {node: '>=18'} @@ -13181,11 +12974,6 @@ packages: react: '*' react-native: '*' - react-native-url-polyfill@2.0.0: - resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==} - peerDependencies: - react-native: '*' - react-native-worklets@0.7.2: resolution: {integrity: sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==} peerDependencies: @@ -13204,11 +12992,6 @@ packages: '@types/react': optional: true - react-player@2.10.1: - resolution: {integrity: sha512-ova0jY1Y1lqLYxOehkzbNEju4rFXYVkr5rdGD71nsiG4UKPzRXQPTd3xjoDssheoMNjZ51mjT5ysTrdQ2tEvsg==} - peerDependencies: - react: '>=16.6.0' - react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -13261,25 +13044,12 @@ packages: '@types/react': optional: true - react-textarea-autosize@8.5.9: - resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} - engines: {node: '>=10'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-turnstile@1.1.5: resolution: {integrity: sha512-VTL5OeHAatzCEVQxAZox70/TPmhKxEbNgtr++dg+8zm9QrWKuoU9E0+7gqmycOSCDZuJFzvMMLKQb5PVUPLV6w==} peerDependencies: react: '>= 16.13.1' react-dom: '>= 16.13.1' - react-virtuoso@2.19.1: - resolution: {integrity: sha512-zF6MAwujNGy2nJWCx/Df92ay/RnV2Kj4glUZfdyadI4suAn0kAZHB1BeI7yPFVp2iSccLzFlszhakWyr+fJ4Dw==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16 || >=17 || >= 18' - react-dom: '>=16 || >=17 || >= 18' - react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -13741,9 +13511,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-markdown@0.7.3: - resolution: {integrity: sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==} - simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -13892,90 +13659,6 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} - stream-chat-expo@8.13.7: - resolution: {integrity: sha512-gYHDqiLjTTJx2HRNgYU8SD7mt6keseJOe3NizHueBL/83kGf0iioHr9xOyqjIJ/OKIighZnqUwgOVQNMfoKNEQ==} - peerDependencies: - expo: '>=51.0.0' - expo-audio: '*' - expo-av: '*' - expo-clipboard: '*' - expo-document-picker: '*' - expo-file-system: '*' - expo-haptics: '*' - expo-image-manipulator: '*' - expo-image-picker: '*' - expo-media-library: '*' - expo-sharing: '*' - expo-video: '*' - peerDependenciesMeta: - expo-audio: - optional: true - expo-av: - optional: true - expo-clipboard: - optional: true - expo-document-picker: - optional: true - expo-file-system: - optional: true - expo-haptics: - optional: true - expo-image-picker: - optional: true - expo-media-library: - optional: true - expo-sharing: - optional: true - expo-video: - optional: true - - stream-chat-react-native-core@8.13.7: - resolution: {integrity: sha512-77lgyDArQaH04lcgJ0uUhNc0fm8oGvTFDs5Tjb7nn9ym+A4yUV6q4mKEtPpgqat+fhGvEBy6vnRG2F1Qx3Vr7A==} - peerDependencies: - '@emoji-mart/data': '>=1.1.0' - '@op-engineering/op-sqlite': '>=14.0.0' - '@react-native-community/netinfo': '>=11.3.1' - '@shopify/flash-list': '>=2.1.0' - emoji-mart: '>=5.4.0' - react-native: '>=0.73.0' - react-native-gesture-handler: '>=2.18.0' - react-native-reanimated: '>=3.16.0' - react-native-safe-area-context: '>=5.4.1' - react-native-svg: '>=15.8.0' - peerDependenciesMeta: - '@emoji-mart/data': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@shopify/flash-list': - optional: true - emoji-mart: - optional: true - - stream-chat-react@13.14.2: - resolution: {integrity: sha512-2q6BuvHryfEzq6N8vs2e8b1iW4O7Aa72fMkhXqsGuP0jT6Vl8x4E+yEIHLlUho6jXDcGQGirqgETuRX3X53odw==} - peerDependencies: - '@breezystack/lamejs': ^1.2.7 - '@emoji-mart/data': ^1.1.0 - '@emoji-mart/react': ^1.1.0 - emoji-mart: ^5.4.0 - react: ^19.0.0 || ^18.0.0 || ^17.0.0 - react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^9.27.2 - peerDependenciesMeta: - '@breezystack/lamejs': - optional: true - '@emoji-mart/data': - optional: true - '@emoji-mart/react': - optional: true - emoji-mart: - optional: true - - stream-chat@9.38.0: - resolution: {integrity: sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg==} - engines: {node: '>=18'} - stream-http@3.2.0: resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} @@ -14162,9 +13845,6 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -14515,9 +14195,6 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unist-builder@4.0.0: - resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -14583,38 +14260,11 @@ packages: '@types/react': optional: true - use-composed-ref@1.4.0: - resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - use-isomorphic-layout-effect@1.2.1: - resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-latest-callback@0.2.6: resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} peerDependencies: react: '>=16.8' - use-latest@1.3.0: - resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -14637,9 +14287,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - util@0.10.4: - resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} - util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} @@ -14855,10 +14502,6 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@5.0.0: - resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} - engines: {node: '>=8'} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -14903,10 +14546,6 @@ packages: whatwg-url-minimum@0.1.1: resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} - whatwg-url-without-unicode@8.0.0-3: - resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} - engines: {node: '>=10'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -16635,8 +16274,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@braintree/sanitize-url@6.0.4': {} - '@chat-adapter/shared@4.20.1': dependencies: chat: 4.20.1 @@ -17472,33 +17109,8 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@floating-ui/react@0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tabbable: 6.4.0 - '@floating-ui/utils@0.2.11': {} - '@gorhom/bottom-sheet@5.1.8(patch_hash=c5eaae9a28f5662f32d66e0129609680309eddc44720d6a2b1e02bbc2b5dd11f)(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': - dependencies: - '@gorhom/portal': 1.0.14(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - invariant: 2.2.4 - react: 19.2.0 - react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - react-native-reanimated: 4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - optionalDependencies: - '@types/react': 19.2.14 - - '@gorhom/portal@1.0.14(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': - dependencies: - nanoid: 3.3.11 - react: 19.2.0 - react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) - '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -19773,42 +19385,6 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-aria/focus@3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) - '@swc/helpers': 0.5.15 - clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/interactions@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.33.1(react@19.2.4) - '@swc/helpers': 0.5.15 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@react-aria/ssr@3.9.10(react@19.2.4)': - dependencies: - '@swc/helpers': 0.5.15 - react: 19.2.4 - - '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.4) - '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.4) - '@react-types/shared': 3.33.1(react@19.2.4) - '@swc/helpers': 0.5.15 - clsx: 2.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - '@react-native-community/netinfo@11.5.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': dependencies: react: 19.2.0 @@ -20003,19 +19579,6 @@ snapshots: dependencies: nanoid: 3.3.11 - '@react-stately/flags@3.1.2': - dependencies: - '@swc/helpers': 0.5.15 - - '@react-stately/utils@3.11.0(react@19.2.4)': - dependencies: - '@swc/helpers': 0.5.15 - react: 19.2.4 - - '@react-types/shared@3.33.1(react@19.2.4)': - dependencies: - react: 19.2.4 - '@redis/bloom@5.11.0(@redis/client@5.11.0)': dependencies: '@redis/client': 5.11.0 @@ -21571,15 +21134,6 @@ snapshots: dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@stream-io/escape-string-regexp@5.0.1': - optional: true - - '@stream-io/transliterate@1.5.5': - dependencies: - '@stream-io/escape-string-regexp': 5.0.1 - lodash.deburr: 4.1.0 - optional: true - '@streamparser/json@0.0.22': {} '@stripe/stripe-js@5.10.0': {} @@ -22198,13 +21752,6 @@ snapshots: '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@virtuoso.dev/react-urx@0.2.13(react@19.2.4)': - dependencies: - '@virtuoso.dev/urx': 0.2.13 - react: 19.2.4 - - '@virtuoso.dev/urx@0.2.13': {} - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -22673,8 +22220,6 @@ snapshots: asynckit@0.4.0: {} - attr-accept@2.2.5: {} - auto-bind@5.0.1: {} available-typed-arrays@1.0.7: @@ -23646,8 +23191,6 @@ snapshots: date-fns@4.1.0: {} - dayjs@1.11.13: {} - dayjs@1.11.20: {} debounce@1.2.1: {} @@ -24002,8 +23545,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - emojis-list@3.0.0: {} encodeurl@1.0.2: {} @@ -24406,11 +23947,6 @@ snapshots: dependencies: expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - expo-image-manipulator@55.0.14(expo@55.0.12): - dependencies: - expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - expo-image-loader: 55.0.0(expo@55.0.12) - expo-image-picker@55.0.17(expo@55.0.12): dependencies: expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) @@ -24752,10 +24288,6 @@ snapshots: fflate@0.8.2: {} - file-selector@2.1.2: - dependencies: - tslib: 2.8.1 - filesize@10.1.6: {} filing-cabinet@5.2.0: @@ -24850,8 +24382,6 @@ snapshots: path-exists: 5.0.0 unicorn-magic: 0.1.0 - fix-webm-duration@1.0.6: {} - flat-cache@3.2.0: dependencies: flatted: 3.4.1 @@ -25132,17 +24662,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-find-and-replace@5.0.1: - dependencies: - '@types/hast': 3.0.4 - escape-string-regexp: 5.0.0 - hast-util-is-element: 3.0.0 - unist-util-visit-parents: 6.0.2 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 @@ -25307,12 +24826,6 @@ snapshots: husky@9.1.7: {} - i18next@25.10.4(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.29.2 - optionalDependencies: - typescript: 5.9.3 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -25362,8 +24875,6 @@ snapshots: indent-string@5.0.0: {} - inherits@2.0.3: {} - inherits@2.0.4: {} ini@1.3.8: {} @@ -25411,8 +24922,6 @@ snapshots: interpret@3.1.1: {} - intl-pluralrules@2.0.1: {} - invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -25546,10 +25055,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): - dependencies: - ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - istanbul-lib-coverage@3.2.2: {} istanbul-lib-hook@3.0.0: @@ -25746,25 +25251,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -26416,19 +25902,6 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): - dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -26657,10 +26130,6 @@ snapshots: dependencies: uc.micro: 2.1.0 - linkifyjs@4.3.2: {} - - load-script@1.0.0: {} - loader-runner@4.3.1: {} loader-utils@2.0.4: @@ -26687,9 +26156,6 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.deburr@4.1.0: - optional: true - lodash.flattendeep@4.4.0: {} lodash.includes@4.3.0: {} @@ -26708,16 +26174,12 @@ snapshots: lodash.merge@4.6.2: {} - lodash.mergewith@4.6.2: {} - lodash.once@4.1.1: {} lodash.snakecase@4.1.1: {} lodash.throttle@4.1.1: {} - lodash.uniqby@4.7.0: {} - lodash@4.17.23: {} log-symbols@2.2.0: @@ -27487,8 +26949,6 @@ snapshots: mime@3.0.0: {} - mime@4.1.0: {} - mimic-fn@1.2.0: {} mimic-fn@2.1.0: {} @@ -28152,11 +27612,6 @@ snapshots: path-type@4.0.0: {} - path@0.12.7: - dependencies: - process: 0.11.10 - util: 0.10.4 - pathe@2.0.3: {} pathval@2.0.1: {} @@ -28428,12 +27883,6 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - property-information@7.1.0: {} protobufjs@7.5.4: @@ -28577,23 +28026,12 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-dropzone@14.4.1(react@19.2.4): - dependencies: - attr-accept: 2.2.5 - file-selector: 2.1.2 - prop-types: 15.8.1 - react: 19.2.4 - react-fast-compare@3.2.2: {} react-freeze@1.0.4(react@19.2.0): dependencies: react: 19.2.0 - react-image-gallery@1.2.12(react@19.2.4): - dependencies: - react: 19.2.4 - react-is@16.13.1: {} react-is@18.3.1: {} @@ -28618,24 +28056,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.4): - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/react': 19.2.14 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 19.2.4 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - react-native-appsflyer@6.17.9: {} react-native-css@3.0.6(@expo/metro-config@55.0.14(bufferutil@4.1.0)(expo@55.0.12)(typescript@5.9.3)(utf-8-validate@6.0.6))(lightningcss@1.30.1)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): @@ -28670,16 +28090,6 @@ snapshots: react: 19.2.0 react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) - react-native-lightbox@0.7.0: - dependencies: - prop-types: 15.8.1 - - react-native-markdown-package@1.8.2: - dependencies: - lodash: 4.17.23 - react-native-lightbox: 0.7.0 - simple-markdown: 0.7.3 - react-native-marked@8.0.1(react-native-svg@15.15.3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: '@jsamr/counter-style': 2.0.2 @@ -28726,11 +28136,6 @@ snapshots: react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6)): - dependencies: - react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) - whatwg-url-without-unicode: 8.0.0-3 - react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: '@babel/core': 7.29.0 @@ -28798,15 +28203,6 @@ snapshots: - supports-color - utf-8-validate - react-player@2.10.1(react@19.2.4): - dependencies: - deepmerge: 4.3.1 - load-script: 1.0.0 - memoize-one: 5.2.1 - prop-types: 15.8.1 - react: 19.2.4 - react-fast-compare: 3.2.2 - react-reconciler@0.33.0(react@19.2.4): dependencies: react: 19.2.4 @@ -28877,27 +28273,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): - dependencies: - '@babel/runtime': 7.29.2 - react: 19.2.4 - use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) - use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) - transitivePeerDependencies: - - '@types/react' - react-turnstile@1.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-virtuoso@2.19.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@virtuoso.dev/react-urx': 0.2.13(react@19.2.4) - '@virtuoso.dev/urx': 0.2.13 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react@19.2.0: {} react@19.2.4: {} @@ -29542,10 +28922,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-markdown@0.7.3: - dependencies: - '@types/react': 19.2.14 - simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -29717,133 +29093,6 @@ snapshots: stream-buffers@2.2.0: {} - stream-chat-expo@8.13.7(e673e8bffb1896cc06607271df6a38dc): - dependencies: - expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) - expo-image-manipulator: 55.0.14(expo@55.0.12) - mime: 4.1.0 - stream-chat-react-native-core: 8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8) - optionalDependencies: - expo-audio: 55.0.12(expo-asset@55.0.13(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3))(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - expo-clipboard: 55.0.12(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - expo-document-picker: 55.0.12(expo@55.0.12) - expo-file-system: 55.0.15(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6)) - expo-haptics: 55.0.13(expo@55.0.12) - expo-image-picker: 55.0.17(expo@55.0.12) - expo-sharing: 55.0.17(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - expo-video: 55.0.14(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - transitivePeerDependencies: - - '@emoji-mart/data' - - '@op-engineering/op-sqlite' - - '@react-native-community/netinfo' - - '@shopify/flash-list' - - '@types/react' - - '@types/react-native' - - bufferutil - - debug - - emoji-mart - - react - - react-native - - react-native-gesture-handler - - react-native-reanimated - - react-native-safe-area-context - - react-native-svg - - typescript - - utf-8-validate - - stream-chat-react-native-core@8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8): - dependencies: - '@gorhom/bottom-sheet': 5.1.8(patch_hash=c5eaae9a28f5662f32d66e0129609680309eddc44720d6a2b1e02bbc2b5dd11f)(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - '@react-native-community/netinfo': 11.5.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - '@ungap/structured-clone': 1.3.0 - dayjs: 1.11.13 - emoji-regex: 10.6.0 - i18next: 25.10.4(typescript@5.9.3) - intl-pluralrules: 2.0.1 - linkifyjs: 4.3.2 - lodash-es: 4.17.23 - mime-types: 2.1.35 - path: 0.12.7 - react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - react-native-markdown-package: 1.8.2 - react-native-reanimated: 4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - react-native-safe-area-context: 5.6.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - react-native-svg: 15.15.3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - react-native-url-polyfill: 2.0.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6)) - stream-chat: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - use-sync-external-store: 1.6.0(react@19.2.0) - optionalDependencies: - '@emoji-mart/data': 1.2.1 - '@shopify/flash-list': 2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) - emoji-mart: 5.6.0 - transitivePeerDependencies: - - '@types/react' - - '@types/react-native' - - bufferutil - - debug - - react - - typescript - - utf-8-validate - - stream-chat-react@13.14.2(@emoji-mart/data@1.2.1)(@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.4))(@types/react@19.2.14)(emoji-mart@5.6.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(stream-chat@9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3): - dependencies: - '@braintree/sanitize-url': 6.0.4 - '@floating-ui/react': 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - clsx: 2.1.1 - dayjs: 1.11.20 - emoji-regex: 9.2.2 - fix-webm-duration: 1.0.6 - hast-util-find-and-replace: 5.0.1 - i18next: 25.10.4(typescript@5.9.3) - linkifyjs: 4.3.2 - lodash.debounce: 4.0.8 - lodash.mergewith: 4.6.2 - lodash.throttle: 4.1.1 - lodash.uniqby: 4.7.0 - nanoid: 3.3.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-dropzone: 14.4.1(react@19.2.4) - react-fast-compare: 3.2.2 - react-image-gallery: 1.2.12(react@19.2.4) - react-markdown: 9.1.0(@types/react@19.2.14)(react@19.2.4) - react-player: 2.10.1(react@19.2.4) - react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4) - react-virtuoso: 2.19.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - remark-gfm: 4.0.1 - stream-chat: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - tslib: 2.8.1 - unist-builder: 4.0.0 - unist-util-visit: 5.1.0 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - '@emoji-mart/data': 1.2.1 - '@emoji-mart/react': 1.1.1(emoji-mart@5.6.0)(react@19.2.4) - '@stream-io/transliterate': 1.5.5 - emoji-mart: 5.6.0 - transitivePeerDependencies: - - '@types/react' - - supports-color - - typescript - - stream-chat@9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): - dependencies: - '@types/jsonwebtoken': 9.0.10 - '@types/ws': 8.18.1 - axios: 1.15.0 - base64-js: 1.5.1 - form-data: 4.0.5 - isomorphic-ws: 5.0.0(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - jsonwebtoken: 9.0.3 - linkifyjs: 4.3.2 - ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - bufferutil - - debug - - utf-8-validate - stream-http@3.2.0: dependencies: builtin-status-codes: 3.0.0 @@ -30028,8 +29277,6 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tabbable@6.4.0: {} - tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -30372,10 +29619,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unist-builder@4.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -30471,29 +29714,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - use-latest-callback@0.2.6(react@19.2.0): dependencies: react: 19.2.0 - use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0): dependencies: detect-node-es: 1.1.0 @@ -30524,10 +29748,6 @@ snapshots: util-deprecate@1.0.2: {} - util@0.10.4: - dependencies: - inherits: 2.0.3 - util@0.12.5: dependencies: inherits: 2.0.4 @@ -30896,8 +30116,6 @@ snapshots: webidl-conversions@3.0.1: {} - webidl-conversions@5.0.0: {} - webidl-conversions@7.0.0: {} webpack-bundle-analyzer@4.10.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): @@ -31049,12 +30267,6 @@ snapshots: whatwg-url-minimum@0.1.1: {} - whatwg-url-without-unicode@8.0.0-3: - dependencies: - buffer: 5.7.1 - punycode: 2.3.1 - webidl-conversions: 5.0.0 - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 diff --git a/services/kiloclaw/controller/src/config-writer.test.ts b/services/kiloclaw/controller/src/config-writer.test.ts index fe6437ee99..3cf8836666 100644 --- a/services/kiloclaw/controller/src/config-writer.test.ts +++ b/services/kiloclaw/controller/src/config-writer.test.ts @@ -786,44 +786,6 @@ describe('generateBaseConfig', () => { expect(config.channels.slack).toBeUndefined(); }); - // ─── Stream Chat (default channel) ─────────────────────────────────────── - - it('configures Stream Chat channel and plugin when all three vars are set', () => { - const { deps } = fakeDeps(); - const env = { - ...minimalEnv(), - STREAM_CHAT_API_KEY: 'sc-api-key', - STREAM_CHAT_BOT_USER_ID: 'bot-sandbox-abc', - STREAM_CHAT_BOT_USER_TOKEN: 'sc-bot-token', - }; - const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); - - expect(config.channels.streamchat.apiKey).toBe('sc-api-key'); - expect(config.channels.streamchat.botUserId).toBe('bot-sandbox-abc'); - expect(config.channels.streamchat.botUserToken).toBe('sc-bot-token'); - expect(config.channels.streamchat.botUserName).toBe('KiloClaw'); - expect(config.channels.streamchat.enabled).toBe(true); - expect(config.plugins.entries['openclaw-channel-streamchat'].enabled).toBe(true); - expect(config.plugins.load.paths).toContain( - '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat' - ); - }); - - it('does not configure Stream Chat when any of the three required vars is missing', () => { - const cases = [ - { STREAM_CHAT_API_KEY: 'key', STREAM_CHAT_BOT_USER_ID: 'bot' }, - { STREAM_CHAT_API_KEY: 'key', STREAM_CHAT_BOT_USER_TOKEN: 'token' }, - { STREAM_CHAT_BOT_USER_ID: 'bot', STREAM_CHAT_BOT_USER_TOKEN: 'token' }, - ]; - - for (const partial of cases) { - const { deps } = fakeDeps(); - const env = { ...minimalEnv(), ...partial }; - const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); - expect(config.channels.streamchat).toBeUndefined(); - } - }); - // ─── Kilo Chat ─────────────────────────────────────────────────────────── it('always configures kilo-chat channel and plugin', () => { @@ -861,30 +823,6 @@ describe('generateBaseConfig', () => { expect(config.session.dmScope).toBe('per-peer'); }); - it('does not duplicate the plugin path on repeated generateBaseConfig calls', () => { - const existing = JSON.stringify({ - channels: { streamchat: { apiKey: 'old-key', enabled: true } }, - plugins: { - load: { - paths: ['/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat'], - }, - entries: { 'openclaw-channel-streamchat': { enabled: true } }, - }, - }); - const { deps } = fakeDeps(existing); - const env = { - ...minimalEnv(), - STREAM_CHAT_API_KEY: 'sc-api-key', - STREAM_CHAT_BOT_USER_ID: 'bot-sandbox-abc', - STREAM_CHAT_BOT_USER_TOKEN: 'sc-bot-token', - }; - const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); - - const pluginPath = '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat'; - const paths = config.plugins.load.paths as string[]; - expect(paths.filter(p => p === pluginPath)).toHaveLength(1); - }); - it('does not set gateway auth when OPENCLAW_GATEWAY_TOKEN is missing', () => { const { deps } = fakeDeps(); const env = { ...minimalEnv() }; diff --git a/services/kiloclaw/controller/src/config-writer.ts b/services/kiloclaw/controller/src/config-writer.ts index 0d392e1b6e..6c165fded8 100644 --- a/services/kiloclaw/controller/src/config-writer.ts +++ b/services/kiloclaw/controller/src/config-writer.ts @@ -476,34 +476,6 @@ export function generateBaseConfig( config.plugins.entries.slack.enabled = true; } - // Stream Chat default channel (auto-provisioned at provision time) - if (env.STREAM_CHAT_API_KEY && env.STREAM_CHAT_BOT_USER_ID && env.STREAM_CHAT_BOT_USER_TOKEN) { - config.channels.streamchat = config.channels.streamchat ?? {}; - config.channels.streamchat.apiKey = env.STREAM_CHAT_API_KEY; - config.channels.streamchat.botUserId = env.STREAM_CHAT_BOT_USER_ID; - config.channels.streamchat.botUserToken = env.STREAM_CHAT_BOT_USER_TOKEN; - config.channels.streamchat.botUserName = 'KiloClaw'; - config.channels.streamchat.enabled = true; - - config.plugins = config.plugins ?? {}; - config.plugins.load = config.plugins.load ?? {}; - config.plugins.load.paths = Array.isArray(config.plugins.load.paths) - ? config.plugins.load.paths - : []; - const pluginPath = '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat'; - if (!(config.plugins.load.paths as string[]).includes(pluginPath)) { - (config.plugins.load.paths as string[]).push(pluginPath); - } - - config.plugins.entries = config.plugins.entries ?? {}; - // Entry key must match the plugin's manifest id (openclaw.plugin.json). - // The fork's manifest declares id "openclaw-channel-streamchat" to align - // with the idHint that OpenClaw derives from the package name. - const scEntry = 'openclaw-channel-streamchat'; - config.plugins.entries[scEntry] = config.plugins.entries[scEntry] ?? {}; - config.plugins.entries[scEntry].enabled = true; - } - // Session — default DM scope to per-channel-peer so each channel+peer // combination gets its own session. OpenClaw's onboard sets this for new // instances, but legacy instances may not have it. diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index a8de59e3aa..75e5bd71f4 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -128,18 +128,6 @@ vi.mock('../utils/encryption', async () => { }; }); -// -- Mock stream-chat client -- -vi.mock('../stream-chat/client', () => ({ - setupDefaultStreamChatChannel: vi.fn().mockResolvedValue({ - apiKey: 'sc-api-key', - botUserId: 'bot-sandbox-1', - botUserToken: 'sc-bot-token', - channelId: 'default-sandbox-1', - }), - createShortLivedUserToken: vi.fn().mockResolvedValue('short-lived-token'), - deactivateStreamChatUsers: vi.fn().mockResolvedValue(undefined), -})); - import { KiloClawInstance } from './kiloclaw-instance'; import { buildChannelConfigPatch } from './kiloclaw-instance/channel-config'; import * as flyClient from '../fly/client'; @@ -149,7 +137,6 @@ import * as gatewayEnv from '../gateway/env'; import * as regions from './regions'; import { resolveLatestVersion } from '../lib/image-version'; import { selectImageVersionForInstance } from '../lib/version-rollout'; -import { setupDefaultStreamChatChannel } from '../stream-chat/client'; import { verifyKiloToken } from '@kilocode/worker-utils'; import { ALARM_INTERVAL_RUNNING_MS, @@ -9196,215 +9183,3 @@ describe('tryMarkInstanceReady', () => { expect(storage._store.get('instanceReadyEmailSent')).toBe(true); }); }); - -// ============================================================================ -// Stream Chat backfill -// ============================================================================ - -describe('Stream Chat backfill on provision', () => { - beforeEach(() => { - (flyClient.createVolumeWithFallback as Mock).mockResolvedValue({ - id: 'vol-1', - region: 'iad', - }); - (flyClient.getVolume as Mock).mockResolvedValue({ id: 'vol-1', region: 'iad' }); - (flyClient.createMachine as Mock).mockResolvedValue({ id: 'machine-1', region: 'iad' }); - (flyClient.waitForState as Mock).mockResolvedValue(undefined); - (setupDefaultStreamChatChannel as Mock).mockClear(); - }); - - it('provisions Stream Chat on first provision when env vars are present', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage, waitUntilPromises } = createInstance(undefined, env); - - await instance.provision('user-1', {}); - await Promise.all(waitUntilPromises); - - expect(setupDefaultStreamChatChannel).toHaveBeenCalledOnce(); - expect(storage._store.get('streamChatApiKey')).toBe('sc-api-key'); - expect(storage._store.get('streamChatBotUserId')).toBe('bot-sandbox-1'); - expect(storage._store.get('streamChatBotUserToken')).toBe('sc-bot-token'); - expect(storage._store.get('streamChatChannelId')).toBe('default-sandbox-1'); - }); - - it('backfills Stream Chat on re-provision when DO state has no credentials', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage } = createInstance(undefined, env); - await seedRunning(storage); - - (setupDefaultStreamChatChannel as Mock).mockClear(); - await instance.provision('user-1', { kilocodeApiKey: 'new-key' }); - - expect(setupDefaultStreamChatChannel).toHaveBeenCalledOnce(); - expect(storage._store.get('streamChatApiKey')).toBe('sc-api-key'); - expect(storage._store.get('streamChatBotUserId')).toBe('bot-sandbox-1'); - expect(storage._store.get('streamChatBotUserToken')).toBe('sc-bot-token'); - expect(storage._store.get('streamChatChannelId')).toBe('default-sandbox-1'); - }); - - it('skips Stream Chat setup on re-provision when credentials already exist', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage } = createInstance(undefined, env); - await seedRunning(storage, { - streamChatApiKey: 'existing-key', - streamChatBotUserId: 'existing-bot', - streamChatBotUserToken: 'existing-token', - streamChatChannelId: 'existing-channel', - }); - - (setupDefaultStreamChatChannel as Mock).mockClear(); - await instance.provision('user-1', { kilocodeApiKey: 'new-key' }); - - expect(setupDefaultStreamChatChannel).not.toHaveBeenCalled(); - expect(storage._store.get('streamChatApiKey')).toBe('existing-key'); - }); - - it('skips Stream Chat when worker env vars are missing', async () => { - const { instance, storage, waitUntilPromises } = createInstance(); - // Default env does not have STREAM_CHAT_API_KEY / STREAM_CHAT_API_SECRET - - await instance.provision('user-1', {}); - await Promise.all(waitUntilPromises); - - expect(setupDefaultStreamChatChannel).not.toHaveBeenCalled(); - expect(storage._store.get('streamChatApiKey')).toBeUndefined(); - }); - - it('continues provisioning when Stream Chat setup fails (non-fatal)', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage, waitUntilPromises } = createInstance(undefined, env); - - (setupDefaultStreamChatChannel as Mock).mockRejectedValueOnce( - new Error('Stream Chat API down') - ); - - await instance.provision('user-1', {}); - await Promise.all(waitUntilPromises); - - // Provision succeeded despite Stream Chat failure - expect(storage._store.get('status')).toBeTruthy(); - expect(storage._store.get('streamChatApiKey')).toBeUndefined(); - }); -}); - -describe('Stream Chat backfill on restartMachine', () => { - beforeEach(() => { - (flyClient.updateMachine as Mock).mockResolvedValue({ instance_id: 'inst-1' }); - (flyClient.waitForState as Mock).mockResolvedValue(undefined); - (flyClient.getMachine as Mock).mockResolvedValue({ - id: 'machine-1', - state: 'started', - config: { guest: { cpus: 1, memory_mb: 256, cpu_kind: 'shared' } }, - }); - (setupDefaultStreamChatChannel as Mock).mockClear(); - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((url: string) => { - if (typeof url === 'string' && url.includes('/_kilo/gateway/status')) { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ state: 'running' }), - }); - } - return Promise.resolve({ ok: true, status: 200 }); - }) - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('backfills Stream Chat on restart when DO state has no credentials', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage, waitUntilPromises } = createInstance(undefined, env); - await seedRunning(storage); - - const result = await instance.restartMachine(); - expect(result.success).toBe(true); - await Promise.all(waitUntilPromises); - - expect(setupDefaultStreamChatChannel).toHaveBeenCalledOnce(); - expect(storage._store.get('streamChatApiKey')).toBe('sc-api-key'); - expect(storage._store.get('streamChatBotUserId')).toBe('bot-sandbox-1'); - expect(storage._store.get('streamChatBotUserToken')).toBe('sc-bot-token'); - expect(storage._store.get('streamChatChannelId')).toBe('default-sandbox-1'); - }); - - it('skips Stream Chat backfill on restart when credentials already exist', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage, waitUntilPromises } = createInstance(undefined, env); - await seedRunning(storage, { - streamChatApiKey: 'existing-key', - streamChatBotUserId: 'existing-bot', - streamChatBotUserToken: 'existing-token', - streamChatChannelId: 'existing-channel', - }); - - const result = await instance.restartMachine(); - expect(result.success).toBe(true); - await Promise.all(waitUntilPromises); - - expect(setupDefaultStreamChatChannel).not.toHaveBeenCalled(); - expect(storage._store.get('streamChatApiKey')).toBe('existing-key'); - }); - - it('continues restart when Stream Chat backfill fails (non-fatal)', async () => { - const env = createFakeEnv(); - Object.assign(env, { - STREAM_CHAT_API_KEY: 'sc-key', - STREAM_CHAT_API_SECRET: 'sc-secret', - }); - const { instance, storage, waitUntilPromises } = createInstance(undefined, env); - await seedRunning(storage); - - (setupDefaultStreamChatChannel as Mock).mockRejectedValueOnce( - new Error('Stream Chat API down') - ); - - const result = await instance.restartMachine(); - expect(result.success).toBe(true); - await Promise.all(waitUntilPromises); - - // Restart still completes — Stream Chat failure is non-fatal - expect(storage._store.get('streamChatApiKey')).toBeUndefined(); - // Machine was still updated - expect(flyClient.updateMachine).toHaveBeenCalled(); - }); - - it('skips Stream Chat backfill when worker env vars are missing', async () => { - const { instance, storage, waitUntilPromises } = createInstance(); - await seedRunning(storage); - - const result = await instance.restartMachine(); - expect(result.success).toBe(true); - await Promise.all(waitUntilPromises); - - expect(setupDefaultStreamChatChannel).not.toHaveBeenCalled(); - }); -}); diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts index 1621529121..3eaa488c72 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts @@ -215,17 +215,6 @@ export async function buildUserEnvVars( plainEnv.KILOCLAW_GMAIL_LAST_HISTORY_ID = state.gmailLastHistoryId; } - // Stream Chat default channel (auto-provisioned at first provision). - // API key and bot user ID are plaintext; bot user token is sensitive. - if (state.streamChatApiKey && state.streamChatBotUserId && state.streamChatBotUserToken) { - plainEnv.STREAM_CHAT_API_KEY = state.streamChatApiKey; - plainEnv.STREAM_CHAT_BOT_USER_ID = state.streamChatBotUserId; - sensitive.STREAM_CHAT_BOT_USER_TOKEN = state.streamChatBotUserToken; - if (state.streamChatChannelId) { - plainEnv.STREAM_CHAT_DEFAULT_CHANNEL_ID = state.streamChatChannelId; - } - } - // Get the env encryption key from the App DO, creating it if needed. // Instance-keyed DOs get per-instance apps, legacy DOs get per-user apps. // Pass the Instance DO's known flyAppName so the App DO can adopt it if needed diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index 8570fcf1b1..ed69915d98 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -105,11 +105,6 @@ import { runUnexpectedStopRecoveryInBackground, type RecoveryRuntime, } from './recovery'; -import { - setupDefaultStreamChatChannel, - createShortLivedUserToken, - deactivateStreamChatUsers, -} from '../../stream-chat/client'; import { writeEvent, safeInstanceIdFromSandboxId } from '../../utils/analytics'; import type { KiloClawEventData, KiloClawEventName } from '../../utils/analytics'; import { getProviderAdapter, resolveDefaultProvider } from '../../providers'; @@ -804,47 +799,6 @@ export class KiloClawInstance extends DurableObject { }); } - // Set up the default Stream Chat channel on first provision (best-effort). - // The bot and channel are created server-side here so the API secret never - // reaches the Fly Machine. Failure is non-fatal: the instance will start - // without the Stream Chat channel rather than blocking provisioning. - // Set up or backfill the default Stream Chat channel (best-effort). - // On first provision (isNew) this creates the channel from scratch. - // On re-provision (!isNew) this backfills instances created before the - // feature was added. setupDefaultStreamChatChannel is idempotent - // (upsert users, getOrCreate channel). Failure is non-fatal. - if ( - !this.s.streamChatApiKey && - this.env.STREAM_CHAT_API_KEY && - this.env.STREAM_CHAT_API_SECRET - ) { - try { - const streamChat = await setupDefaultStreamChatChannel( - this.env.STREAM_CHAT_API_KEY, - this.env.STREAM_CHAT_API_SECRET, - sandboxId - ); - this.s.streamChatApiKey = streamChat.apiKey; - this.s.streamChatBotUserId = streamChat.botUserId; - this.s.streamChatBotUserToken = streamChat.botUserToken; - this.s.streamChatChannelId = streamChat.channelId; - await this.persist({ - streamChatApiKey: streamChat.apiKey, - streamChatBotUserId: streamChat.botUserId, - streamChatBotUserToken: streamChat.botUserToken, - streamChatChannelId: streamChat.channelId, - }); - console.log( - `[DO] Stream Chat channel ${isNew ? 'provisioned' : 'backfilled'}:`, - streamChat.channelId - ); - } catch (err) { - doWarn(this.s, 'Stream Chat channel setup failed (non-fatal)', { - error: toLoggable(err), - }); - } - } - if (isNew) { await this.scheduleAlarm(); } @@ -2268,22 +2222,6 @@ export class KiloClawInstance extends DurableObject { value: machineUptimeMs, }); - // Best-effort: deactivate Stream Chat users so any captured tokens become useless. - // Failure is non-fatal — worst case is the same as pre-deactivation behavior. - if (this.env.STREAM_CHAT_API_KEY && this.env.STREAM_CHAT_API_SECRET && this.s.sandboxId) { - try { - await deactivateStreamChatUsers( - this.env.STREAM_CHAT_API_KEY, - this.env.STREAM_CHAT_API_SECRET, - [this.s.sandboxId, `bot-${this.s.sandboxId}`] - ); - } catch (err) { - doWarn(this.s, 'Stream Chat user deactivation failed (non-fatal)', { - error: toLoggable(err), - }); - } - } - // Best-effort: clean up kilo-chat data (conversations, messages, memberships) // for this sandbox. Failure is non-fatal — orphaned data is unreachable. if (this.env.KILO_CHAT && this.s.sandboxId) { @@ -2490,38 +2428,6 @@ export class KiloClawInstance extends DurableObject { }; } - async getStreamChatCredentials(): Promise<{ - apiKey: string; - userId: string; - userToken: string; - channelId: string; - } | null> { - await this.loadState(); - - if ( - !this.s.streamChatApiKey || - !this.env.STREAM_CHAT_API_SECRET || - !this.s.streamChatChannelId || - !this.s.sandboxId - ) { - return null; - } - - // Mint a short-lived token on every request so that revoked users lose - // access when the token expires, without requiring an app-secret rotation. - const userToken = await createShortLivedUserToken( - this.env.STREAM_CHAT_API_SECRET, - this.s.sandboxId - ); - - return { - apiKey: this.s.streamChatApiKey, - userId: this.s.sandboxId, - userToken, - channelId: this.s.streamChatChannelId, - }; - } - async getDebugState(): Promise<{ userId: string | null; sandboxId: string | null; @@ -3332,40 +3238,6 @@ export class KiloClawInstance extends DurableObject { throw new Error('No machine exists'); } - // Backfill Stream Chat for instances created before the feature was added. - // setupDefaultStreamChatChannel is idempotent (upsert users, getOrCreate channel). - if ( - !this.s.streamChatApiKey && - this.env.STREAM_CHAT_API_KEY && - this.env.STREAM_CHAT_API_SECRET && - this.s.sandboxId - ) { - try { - const streamChat = await setupDefaultStreamChatChannel( - this.env.STREAM_CHAT_API_KEY, - this.env.STREAM_CHAT_API_SECRET, - this.s.sandboxId - ); - this.s.streamChatApiKey = streamChat.apiKey; - this.s.streamChatBotUserId = streamChat.botUserId; - this.s.streamChatBotUserToken = streamChat.botUserToken; - this.s.streamChatChannelId = streamChat.channelId; - await this.persist({ - streamChatApiKey: streamChat.apiKey, - streamChatBotUserId: streamChat.botUserId, - streamChatBotUserToken: streamChat.botUserToken, - streamChatChannelId: streamChat.channelId, - }); - doLog(this.s, 'Stream Chat backfilled on restart', { - channelId: streamChat.channelId, - }); - } catch (err) { - doWarn(this.s, 'Stream Chat backfill failed on restart (non-fatal)', { - error: toLoggable(err), - }); - } - } - const { envVars, bootstrapEnv, minSecretsVersion } = await buildUserEnvVars( this.env, this.ctx, diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts index 3c195fc511..7c386f1219 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts @@ -333,10 +333,6 @@ export async function loadState(ctx: DurableObjectState, s: InstanceMutableState // to avoid spurious emails after deploy. s.instanceReadyEmailSent = 'instanceReadyEmailSent' in raw ? d.instanceReadyEmailSent : true; s.customSecretMeta = d.customSecretMeta; - s.streamChatApiKey = d.streamChatApiKey; - s.streamChatBotUserId = d.streamChatBotUserId; - s.streamChatBotUserToken = d.streamChatBotUserToken; - s.streamChatChannelId = d.streamChatChannelId; s.vectorMemoryEnabled = d.vectorMemoryEnabled; s.vectorMemoryModel = d.vectorMemoryModel; s.dreamingEnabled = d.dreamingEnabled; @@ -432,10 +428,6 @@ export function resetMutableState(s: InstanceMutableState): void { s.preRestoreStatus = null; s.pendingRestoreVolumeId = null; s.instanceReadyEmailSent = false; - s.streamChatApiKey = null; - s.streamChatBotUserId = null; - s.streamChatBotUserToken = null; - s.streamChatChannelId = null; s.vectorMemoryEnabled = false; s.vectorMemoryModel = null; s.dreamingEnabled = false; @@ -526,10 +518,6 @@ export function createMutableState(): InstanceMutableState { pendingRestoreVolumeId: null, instanceReadyEmailSent: false, customSecretMeta: null, - streamChatApiKey: null, - streamChatBotUserId: null, - streamChatBotUserToken: null, - streamChatChannelId: null, vectorMemoryEnabled: false, vectorMemoryModel: null, dreamingEnabled: false, diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts index d66b412948..1053fbf8df 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts @@ -132,11 +132,6 @@ export type InstanceMutableState = { pendingRestoreVolumeId: string | null; instanceReadyEmailSent: boolean; customSecretMeta: PersistedState['customSecretMeta']; - // Stream Chat default channel (auto-provisioned) - streamChatApiKey: string | null; - streamChatBotUserId: string | null; - streamChatBotUserToken: string | null; - streamChatChannelId: string | null; vectorMemoryEnabled: boolean; vectorMemoryModel: string | null; dreamingEnabled: boolean; diff --git a/services/kiloclaw/src/gateway/env.ts b/services/kiloclaw/src/gateway/env.ts index c31fe64232..eb02f4ef0e 100644 --- a/services/kiloclaw/src/gateway/env.ts +++ b/services/kiloclaw/src/gateway/env.ts @@ -82,8 +82,6 @@ export type EnvVarsBuild = { const SENSITIVE_KEYS = new Set([ 'KILOCODE_API_KEY', 'OPENCLAW_GATEWAY_TOKEN', - // Stream Chat bot token is auto-provisioned and must stay encrypted in transit - 'STREAM_CHAT_BOT_USER_TOKEN', ...ALL_SECRET_ENV_VARS, ...INTERNAL_SENSITIVE_ENV_VARS, ]); diff --git a/services/kiloclaw/src/routes/kiloclaw.ts b/services/kiloclaw/src/routes/kiloclaw.ts index 720a0935bd..3eb08a44be 100644 --- a/services/kiloclaw/src/routes/kiloclaw.ts +++ b/services/kiloclaw/src/routes/kiloclaw.ts @@ -91,32 +91,6 @@ kiloclaw.get('/status', c => }) ); -// GET /api/kiloclaw/chat-credentials -- Stream Chat credentials for the user's channel -kiloclaw.get('/chat-credentials', c => - instrumented(c, 'GET /api/kiloclaw/chat-credentials', async () => { - const userId = c.get('userId'); - const raw = c.req.query('instanceId'); - if (raw && !InstanceIdParam.safeParse(raw).success) { - return c.json({ error: 'Invalid instance ID' }, 400); - } - const instanceId = raw || undefined; - const doKey = instanceId ?? userId; - const stub = c.env.KILOCLAW_INSTANCE.get(c.env.KILOCLAW_INSTANCE.idFromName(doKey)); - - // When accessing by instanceId, verify the authenticated user owns this instance. - if (instanceId) { - const status = await stub.getStatus(); - if (status.userId !== userId) { - return c.json({ error: 'Access denied' }, 403); - } - } - - const creds = await stub.getStreamChatCredentials(); - - return c.json(creds); - }) -); - /** * Derive per-entry configured status from the catalog. * diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index c0c4323ef2..3c095c0bf3 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -49,7 +49,6 @@ import { deriveGatewayToken } from '../auth/gateway-token'; import { sandboxIdFromUserId } from '../auth/sandbox-id'; import { writeEvent } from '../utils/analytics'; import { deriveHttpEventName } from '../middleware/analytics'; -import { sendMessage } from '../stream-chat/client'; import { assertAvailableProvider } from '../providers'; import type { ProviderCapability } from '../providers/types'; import { @@ -2747,31 +2746,6 @@ platform.get('/status', async c => { } }); -// GET /api/platform/stream-chat-credentials?userId=...&instanceId=... -platform.get('/stream-chat-credentials', async c => { - const userId = setValidatedQueryUserId(c); - if (!userId) { - return c.json({ error: 'userId query parameter is required' }, 400); - } - const iidResult = parseInstanceIdQuery(c); - if ('error' in iidResult) return iidResult.error; - const { instanceId } = iidResult; - - try { - const creds = await withResolvedDORetry( - c.env, - userId, - instanceId, - stub => stub.getStreamChatCredentials(), - 'getStreamChatCredentials' - ); - return c.json(creds); - } catch (err) { - const { message, status } = sanitizeError(err, 'stream-chat-credentials'); - return jsonError(message, status); - } -}); - const MAX_INBOUND_EMAIL_TITLE_SLUG_LENGTH = 80; const InboundEmailSchema = z.object({ @@ -3051,74 +3025,6 @@ platform.post('/inbound-email', async c => { } }); -// POST /api/platform/send-chat-message -// Send a message to a KiloClaw instance's Stream Chat channel as the human user. -// The OpenClaw bot picks it up and responds as if the user typed it. -const SendChatMessageSchema = z.object({ - userId: z.string().min(1), - instanceId: z.string().uuid().optional(), - message: z.string().min(1).max(32_000), -}); - -platform.post('/send-chat-message', async c => { - const body: unknown = await c.req.json().catch(() => null); - const parsed = SendChatMessageSchema.safeParse(body); - if (!parsed.success) { - return jsonError('Invalid request body: userId and message are required', 400); - } - - const { userId, instanceId, message } = parsed.data; - c.set('userId', userId); - - const apiKey = c.env.STREAM_CHAT_API_KEY; - const apiSecret = c.env.STREAM_CHAT_API_SECRET; - if (!apiKey || !apiSecret) { - return jsonError('Stream Chat is not configured', 503); - } - - try { - // Use instanceId as the DO key when available (matches how other endpoints resolve DOs). - // Falls back to userId for backward compatibility with triggers that predate instanceId. - const creds = await withResolvedDORetry( - c.env, - userId, - instanceId, - stub => stub.getStreamChatCredentials(), - 'getStreamChatCredentials' - ); - - if (!creds) { - return jsonError('Stream Chat is not set up for this instance', 404); - } - - await sendMessage(apiKey, apiSecret, creds.channelId, creds.userId, message); - - writeEvent(c.env, { - event: 'instance.webhook_chat_message_sent', - delivery: 'http', - route: '/api/platform/send-chat-message', - userId, - instanceId: instanceId ?? undefined, - channelId: creds.channelId, - }); - - return c.json({ success: true, channelId: creds.channelId }); - } catch (err) { - const { message: errMsg, status } = sanitizeError(err, 'send-chat-message'); - - writeEvent(c.env, { - event: 'instance.webhook_chat_message_failed', - delivery: 'http', - route: '/api/platform/send-chat-message', - userId, - instanceId: instanceId ?? undefined, - error: errMsg, - }); - - return jsonError(errMsg, status); - } -}); - // GET /api/platform/debug-status?userId=...&instanceId=... // Internal/admin-only debug status that includes DO destroy internals. platform.get('/debug-status', async c => { diff --git a/services/kiloclaw/src/schemas/instance-config.ts b/services/kiloclaw/src/schemas/instance-config.ts index e0d478e0f1..21bacb38bf 100644 --- a/services/kiloclaw/src/schemas/instance-config.ts +++ b/services/kiloclaw/src/schemas/instance-config.ts @@ -413,12 +413,6 @@ export const PersistedStateSchema = z.object({ // Metadata for custom (non-catalog) secrets: env var name → { configPath? }. // configPath is a JSON dot-notation path for patching into openclaw.json at boot. customSecretMeta: z.record(z.string(), CustomSecretMetaSchema).nullable().default(null), - // Stream Chat default channel (auto-provisioned on first instance creation). - // Null on existing instances (pre-Stream Chat) and when STREAM_CHAT_API_KEY is not set. - streamChatApiKey: z.string().nullable().default(null), - streamChatBotUserId: z.string().nullable().default(null), - streamChatBotUserToken: z.string().nullable().default(null), - streamChatChannelId: z.string().nullable().default(null), // Vector memory: whether the builtin embedding-backed memory search is enabled. vectorMemoryEnabled: z.boolean().default(false), // Vector memory: embedding model ID (e.g. "mistralai/mistral-embed-2312"). diff --git a/services/kiloclaw/src/stream-chat/client.test.ts b/services/kiloclaw/src/stream-chat/client.test.ts deleted file mode 100644 index ce0f495bcb..0000000000 --- a/services/kiloclaw/src/stream-chat/client.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - createServerToken, - createUserToken, - createShortLivedUserToken, - upsertStreamChatUsers, - getOrCreateStreamChatChannel, - deactivateStreamChatUsers, - reactivateStreamChatUsers, - setupDefaultStreamChatChannel, - sendMessage, -} from './client'; - -// Decode a JWT payload without verifying signature (for test assertions only). -function decodeJwtPayload(token: string): Record { - const [, payload] = token.split('.'); - return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record; -} - -describe('createServerToken', () => { - it('produces a JWT with server: true in the payload', async () => { - const token = await createServerToken('test-secret'); - expect(token.split('.')).toHaveLength(3); - const payload = decodeJwtPayload(token); - expect(payload.server).toBe(true); - }); - - it('produces different tokens for different secrets', async () => { - const t1 = await createServerToken('secret-a'); - const t2 = await createServerToken('secret-b'); - expect(t1).not.toBe(t2); - }); -}); - -describe('createUserToken', () => { - it('produces a JWT with user_id in the payload', async () => { - const token = await createUserToken('test-secret', 'user-123'); - const payload = decodeJwtPayload(token); - expect(payload.user_id).toBe('user-123'); - }); - - it('produces different tokens for different user IDs', async () => { - const t1 = await createUserToken('secret', 'user-a'); - const t2 = await createUserToken('secret', 'user-b'); - expect(t1).not.toBe(t2); - }); -}); - -describe('createShortLivedUserToken', () => { - it('produces a JWT with user_id, iat, and exp in the payload', async () => { - const token = await createShortLivedUserToken('test-secret', 'user-456'); - const payload = decodeJwtPayload(token); - expect(payload.user_id).toBe('user-456'); - expect(payload.iat).toEqual(expect.any(Number)); - expect(payload.exp).toEqual(expect.any(Number)); - }); - - it('sets exp roughly 6 hours after iat', async () => { - const token = await createShortLivedUserToken('test-secret', 'user-ttl'); - const payload = decodeJwtPayload(token); - const iat = payload.iat as number; - const exp = payload.exp as number; - const sixHoursInSeconds = 6 * 60 * 60; - // Allow 5 seconds of tolerance for test execution time - expect(exp - iat).toBeGreaterThanOrEqual(sixHoursInSeconds - 5); - expect(exp - iat).toBeLessThanOrEqual(sixHoursInSeconds + 5); - }); - - it('produces different tokens for different user IDs', async () => { - const t1 = await createShortLivedUserToken('secret', 'user-a'); - const t2 = await createShortLivedUserToken('secret', 'user-b'); - expect(t1).not.toBe(t2); - }); -}); - -describe('upsertStreamChatUsers', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - it('sends a POST to /users with correct headers and body', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await upsertStreamChatUsers('my-api-key', 'server-jwt', [ - { id: 'user-1', name: 'User One' }, - { id: 'bot-1', name: 'Bot One', role: 'admin' }, - ]); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, opts] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(url).toBe('https://chat.stream-io-api.com/users?api_key=my-api-key'); - expect(opts.method).toBe('POST'); - expect(opts.headers['Stream-Auth-Type']).toBe('jwt'); - expect(opts.headers['Authorization']).toBe('server-jwt'); - const body = JSON.parse(opts.body as string) as { users: Record }; - expect(body.users['user-1']).toMatchObject({ id: 'user-1', name: 'User One' }); - expect(body.users['bot-1']).toMatchObject({ id: 'bot-1', name: 'Bot One', role: 'admin' }); - }); - - it('throws on HTTP error with status and body in the message', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => 'Unauthorized', - }); - - await expect(upsertStreamChatUsers('key', 'jwt', [{ id: 'x', name: 'X' }])).rejects.toThrow( - '403' - ); - }); -}); - -describe('getOrCreateStreamChatChannel', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - it('POSTs to /channels/{type}/{id}/query with correct payload', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await getOrCreateStreamChatChannel('my-key', 'server-jwt', 'messaging', 'chan-123', { - created_by_id: 'user-1', - members: ['user-1', 'bot-1'], - name: 'Test Channel', - }); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, opts] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(url).toBe( - 'https://chat.stream-io-api.com/channels/messaging/chan-123/query?api_key=my-key' - ); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body as string) as { data: unknown }; - expect(body.data).toMatchObject({ - created_by_id: 'user-1', - members: ['user-1', 'bot-1'], - name: 'Test Channel', - }); - }); - - it('throws on HTTP error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 429, - text: async () => 'Rate limited', - }); - - await expect( - getOrCreateStreamChatChannel('key', 'jwt', 'messaging', 'chan-1', { - created_by_id: 'u', - members: ['u', 'b'], - }) - ).rejects.toThrow('429'); - }); -}); - -describe('deactivateStreamChatUsers', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - it('POSTs to /api/v2/users/{id}/deactivate for each user', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - await deactivateStreamChatUsers('my-key', 'my-secret', ['user-1', 'bot-user-1']); - - expect(mockFetch).toHaveBeenCalledTimes(2); - - const [url1, opts1] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(url1).toBe( - 'https://chat.stream-io-api.com/api/v2/users/user-1/deactivate?api_key=my-key' - ); - expect(opts1.method).toBe('POST'); - expect(opts1.headers['Stream-Auth-Type']).toBe('jwt'); - - const [url2] = mockFetch.mock.calls[1] as [string, unknown]; - expect(url2).toBe( - 'https://chat.stream-io-api.com/api/v2/users/bot-user-1/deactivate?api_key=my-key' - ); - }); - - it('silently ignores 404 responses', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'Not Found' }); - - await expect( - deactivateStreamChatUsers('key', 'secret', ['nonexistent']) - ).resolves.toBeUndefined(); - }); - - it('throws on non-404 HTTP errors', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }); - - await expect(deactivateStreamChatUsers('key', 'secret', ['user-1'])).rejects.toThrow('500'); - }); -}); - -describe('reactivateStreamChatUsers', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - it('POSTs to /api/v2/users/{id}/reactivate for each user', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - await reactivateStreamChatUsers('my-key', 'my-secret', ['user-1', 'bot-user-1']); - - expect(mockFetch).toHaveBeenCalledTimes(2); - - const [url1, opts1] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(url1).toBe( - 'https://chat.stream-io-api.com/api/v2/users/user-1/reactivate?api_key=my-key' - ); - expect(opts1.method).toBe('POST'); - expect(opts1.headers['Stream-Auth-Type']).toBe('jwt'); - - const [url2] = mockFetch.mock.calls[1] as [string, unknown]; - expect(url2).toBe( - 'https://chat.stream-io-api.com/api/v2/users/bot-user-1/reactivate?api_key=my-key' - ); - }); - - it('silently ignores 404 responses', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'Not Found' }); - - await expect( - reactivateStreamChatUsers('key', 'secret', ['nonexistent']) - ).resolves.toBeUndefined(); - }); - - it('throws on non-404 HTTP errors', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }); - - await expect(reactivateStreamChatUsers('key', 'secret', ['user-1'])).rejects.toThrow('500'); - }); -}); - -describe('setupDefaultStreamChatChannel', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - function mockOk() { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - } - - it('reactivates, upserts, creates channel, and returns correct IDs and bot token', async () => { - mockOk(); - const result = await setupDefaultStreamChatChannel('api-key', 'api-secret', 'sandbox-abc'); - - // 4 fetch calls: 2x reactivate + upsertUsers + getOrCreateChannel - expect(mockFetch).toHaveBeenCalledTimes(4); - - // First two calls are reactivate (human + bot) - const [reactivateUrl1] = mockFetch.mock.calls[0] as [string, unknown]; - expect(reactivateUrl1).toContain('/api/v2/users/sandbox-abc/reactivate'); - const [reactivateUrl2] = mockFetch.mock.calls[1] as [string, unknown]; - expect(reactivateUrl2).toContain('/api/v2/users/bot-sandbox-abc/reactivate'); - - expect(result.apiKey).toBe('api-key'); - expect(result.botUserId).toBe('bot-sandbox-abc'); - expect(result.channelId).toBe('default-sandbox-abc'); - - // Bot token should be a valid JWT; human user token is no longer returned - const botPayload = decodeJwtPayload(result.botUserToken); - expect(botPayload.user_id).toBe('bot-sandbox-abc'); - expect(result).not.toHaveProperty('userToken'); - }); - - it('uses correct channel type (messaging)', async () => { - mockOk(); - await setupDefaultStreamChatChannel('key', 'secret', 'sandbox-xyz'); - - // Channel creation is the 4th call (after 2 reactivate + 1 upsert) - const [channelUrl] = mockFetch.mock.calls[3] as [string, unknown]; - expect(channelUrl).toContain('/channels/messaging/'); - expect(channelUrl).toContain('default-sandbox-xyz'); - }); - - it('throws if upsertUsers fails', async () => { - // First two calls (reactivate) succeed, third (upsert) fails - mockFetch - .mockResolvedValueOnce({ ok: true, status: 200 }) // reactivate human - .mockResolvedValueOnce({ ok: true, status: 200 }) // reactivate bot - .mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }); - - await expect(setupDefaultStreamChatChannel('key', 'secret', 'sandbox-fail')).rejects.toThrow( - '500' - ); - }); - - it('throws if getOrCreateChannel fails', async () => { - mockFetch - .mockResolvedValueOnce({ ok: true, status: 200 }) // reactivate human - .mockResolvedValueOnce({ ok: true, status: 200 }) // reactivate bot - .mockResolvedValueOnce({ ok: true, status: 200 }) // upsertUsers succeeds - .mockResolvedValueOnce({ - ok: false, - status: 503, - text: async () => 'Service Unavailable', - }); - - await expect(setupDefaultStreamChatChannel('key', 'secret', 'sandbox-fail2')).rejects.toThrow( - '503' - ); - }); - - it('tolerates reactivate 404 (first provision, users do not exist yet)', async () => { - // Reactivate returns 404, upsert and channel creation succeed - mockFetch - .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'Not Found' }) // reactivate human - .mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'Not Found' }) // reactivate bot - .mockResolvedValueOnce({ ok: true, status: 200 }) // upsertUsers - .mockResolvedValueOnce({ ok: true, status: 200 }); // getOrCreateChannel - - const result = await setupDefaultStreamChatChannel('api-key', 'api-secret', 'sandbox-new'); - expect(result.apiKey).toBe('api-key'); - expect(result.botUserId).toBe('bot-sandbox-new'); - }); -}); - -describe('sendMessage', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - mockFetch.mockReset(); - }); - - it('POSTs to /channels/messaging/{channelId}/message with correct payload', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, status: 201 }); - - await sendMessage( - 'my-api-key', - 'my-api-secret', - 'default-sandbox-abc', - 'sandbox-abc', - 'Hello bot!' - ); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, opts] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - expect(url).toBe( - 'https://chat.stream-io-api.com/channels/messaging/default-sandbox-abc/message?api_key=my-api-key' - ); - expect(opts.method).toBe('POST'); - expect(opts.headers['Content-Type']).toBe('application/json'); - expect(opts.headers['Stream-Auth-Type']).toBe('jwt'); - // Authorization header should be a server JWT - expect(opts.headers['Authorization']).toBeDefined(); - expect(opts.headers['Authorization'].split('.')).toHaveLength(3); - - const body = JSON.parse(opts.body as string) as { message: { text: string; user_id: string } }; - expect(body.message.text).toBe('Hello bot!'); - expect(body.message.user_id).toBe('sandbox-abc'); - }); - - it('uses a server token (server: true) for authentication', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, status: 201 }); - - await sendMessage('key', 'secret', 'chan-1', 'user-1', 'test'); - - const [, opts] = mockFetch.mock.calls[0] as [ - string, - RequestInit & { headers: Record }, - ]; - const payload = decodeJwtPayload(opts.headers['Authorization']); - expect(payload.server).toBe(true); - }); - - it('throws on HTTP error with status and body in the message', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => 'User is deactivated', - }); - - await expect(sendMessage('key', 'secret', 'chan-1', 'user-1', 'test')).rejects.toThrow( - 'Stream Chat sendMessage failed (403): User is deactivated' - ); - }); - - it('preserves HTTP status on the thrown error for upstream handling', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - text: async () => 'Channel not found', - }); - - try { - await sendMessage('key', 'secret', 'chan-1', 'user-1', 'test'); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error & { status: number }).status).toBe(404); - } - }); - - it('handles unreadable error body gracefully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => { - throw new Error('body read error'); - }, - }); - - await expect(sendMessage('key', 'secret', 'chan-1', 'user-1', 'test')).rejects.toThrow( - 'Stream Chat sendMessage failed (500): (unreadable)' - ); - }); -}); diff --git a/services/kiloclaw/src/stream-chat/client.ts b/services/kiloclaw/src/stream-chat/client.ts deleted file mode 100644 index f0324b3730..0000000000 --- a/services/kiloclaw/src/stream-chat/client.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Lightweight Stream Chat server-side client for Cloudflare Workers. - * - * Uses fetch + jose for token generation. Does NOT depend on the `stream-chat` - * npm package which requires Node.js APIs incompatible with CF Workers. - * - * Stream Chat REST API base: https://chat.stream-io-api.com - * Auth: api_key query param + Authorization: header - */ -import { SignJWT } from 'jose'; - -const STREAM_CHAT_API_BASE = 'https://chat.stream-io-api.com'; - -/** - * Result of provisioning a Stream Chat default channel for a new KiloClaw instance. - * Does NOT include a human user token — those are minted on demand with a short TTL - * via {@link createShortLivedUserToken}. - */ -export type StreamChatSetup = { - apiKey: string; - /** Bot user ID: `bot-{sandboxId}` */ - botUserId: string; - /** Permanent JWT for the bot user (used by the openclaw-channel-streamchat plugin) */ - botUserToken: string; - /** Default channel ID: `default-{sandboxId}` */ - channelId: string; -}; - -/** - * Generate a Stream Chat server-side JWT. - * Used for admin operations (creating users, channels) from the CF Worker. - * Payload: `{ server: true }` — gives full API access. - */ -export async function createServerToken(apiSecret: string): Promise { - const secretBytes = new TextEncoder().encode(apiSecret); - return new SignJWT({ server: true }).setProtectedHeader({ alg: 'HS256' }).sign(secretBytes); -} - -/** - * Generate a permanent Stream Chat user JWT for bot authentication. - * Payload: `{ user_id: userId }` — scoped to a single user, no expiry. - * For human/browser tokens use {@link createShortLivedUserToken} instead. - */ -export async function createUserToken(apiSecret: string, userId: string): Promise { - const secretBytes = new TextEncoder().encode(apiSecret); - return new SignJWT({ user_id: userId }).setProtectedHeader({ alg: 'HS256' }).sign(secretBytes); -} - -/** Default TTL for browser-facing Stream Chat user tokens. */ -export const USER_TOKEN_TTL = '6h'; - -/** - * Generate a short-lived Stream Chat user JWT for browser authentication. - * Payload: `{ user_id: userId }` with `iat` and `exp` claims. - * The token expires after {@link USER_TOKEN_TTL} so that revoked users lose - * access without requiring an app-secret rotation. - */ -export async function createShortLivedUserToken( - apiSecret: string, - userId: string -): Promise { - const secretBytes = new TextEncoder().encode(apiSecret); - return new SignJWT({ user_id: userId }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(USER_TOKEN_TTL) - .sign(secretBytes); -} - -/** - * Upsert one or more Stream Chat users via the server API. - * Creates the user if it doesn't exist; updates fields if it does. - */ -export async function upsertStreamChatUsers( - apiKey: string, - serverToken: string, - users: ReadonlyArray<{ id: string; name: string; role?: string }> -): Promise { - const usersMap: Record = {}; - for (const user of users) { - usersMap[user.id] = user; - } - - const res = await fetch(`${STREAM_CHAT_API_BASE}/users?api_key=${apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: serverToken, - }, - body: JSON.stringify({ users: usersMap }), - }); - - if (!res.ok) { - const body = await res.text().catch(() => '(unreadable)'); - throw new Error(`Stream Chat upsertUsers failed (${res.status}): ${body}`); - } -} - -/** - * Get or create a Stream Chat channel. - * Idempotent: safe to call on an existing channel. - */ -export async function getOrCreateStreamChatChannel( - apiKey: string, - serverToken: string, - channelType: string, - channelId: string, - data: { created_by_id: string; members: string[]; name?: string } -): Promise { - const res = await fetch( - `${STREAM_CHAT_API_BASE}/channels/${channelType}/${channelId}/query?api_key=${apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: serverToken, - }, - body: JSON.stringify({ data }), - } - ); - - if (!res.ok) { - const body = await res.text().catch(() => '(unreadable)'); - throw new Error(`Stream Chat getOrCreateChannel failed (${res.status}): ${body}`); - } -} - -/** - * Deactivate one or more Stream Chat users via the server API. - * Deactivated users cannot connect to Stream Chat or send/receive messages, - * making any previously issued tokens useless. - * Silently ignores 404 (user not found). Attempts all users before throwing - * so that a transient failure for one user doesn't leave others active. - */ -export async function deactivateStreamChatUsers( - apiKey: string, - apiSecret: string, - userIds: readonly string[] -): Promise { - const serverToken = await createServerToken(apiSecret); - const errors: Error[] = []; - for (const userId of userIds) { - const res = await fetch( - `${STREAM_CHAT_API_BASE}/api/v2/users/${encodeURIComponent(userId)}/deactivate?api_key=${apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: serverToken, - }, - body: JSON.stringify({}), - } - ); - // 404 = user never existed, safe to ignore - if (!res.ok && res.status !== 404) { - const body = await res.text().catch(() => '(unreadable)'); - errors.push( - new Error(`Stream Chat deactivateUser failed for ${userId} (${res.status}): ${body}`) - ); - } - } - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) { - throw new AggregateError(errors, 'Stream Chat deactivateUsers had failures'); - } -} - -/** - * Reactivate one or more previously deactivated Stream Chat users. - * Called during re-provision to ensure users can connect again. - * Silently ignores 404 (user not found). Attempts all users before throwing - * so that a transient failure for one user doesn't leave others deactivated. - */ -export async function reactivateStreamChatUsers( - apiKey: string, - apiSecret: string, - userIds: readonly string[] -): Promise { - const serverToken = await createServerToken(apiSecret); - const errors: Error[] = []; - for (const userId of userIds) { - const res = await fetch( - `${STREAM_CHAT_API_BASE}/api/v2/users/${encodeURIComponent(userId)}/reactivate?api_key=${apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: serverToken, - }, - body: JSON.stringify({}), - } - ); - // 404 = user never existed, safe to ignore - if (!res.ok && res.status !== 404) { - const body = await res.text().catch(() => '(unreadable)'); - errors.push( - new Error(`Stream Chat reactivateUser failed for ${userId} (${res.status}): ${body}`) - ); - } - } - if (errors.length === 1) throw errors[0]; - if (errors.length > 1) { - throw new AggregateError(errors, 'Stream Chat reactivateUsers had failures'); - } -} - -/** - * Provision the default Stream Chat channel for a new KiloClaw instance. - * - * Creates (or re-uses if already existing): - * - A human user with ID `{sandboxId}` - * - A per-instance bot user with ID `bot-{sandboxId}` - * - A messaging channel `default-{sandboxId}` with both as members - * - * Returns tokens and IDs needed to configure the machine and optionally the browser client. - */ -export async function setupDefaultStreamChatChannel( - apiKey: string, - apiSecret: string, - sandboxId: string -): Promise { - const serverToken = await createServerToken(apiSecret); - - const humanUserId = sandboxId; - const botUserId = `bot-${sandboxId}`; - const channelId = `default-${sandboxId}`; - - // Reactivate users in case they were deactivated by a prior destroy. - // This is a no-op for first-time provisioning (404s are silently ignored). - await reactivateStreamChatUsers(apiKey, apiSecret, [humanUserId, botUserId]); - - // Create/upsert both users - await upsertStreamChatUsers(apiKey, serverToken, [ - { id: humanUserId, name: 'User' }, - { id: botUserId, name: 'KiloClaw', role: 'admin' }, - ]); - - // Create the default channel with both members - await getOrCreateStreamChatChannel(apiKey, serverToken, 'messaging', channelId, { - created_by_id: humanUserId, - members: [humanUserId, botUserId], - name: 'KiloClaw', - }); - - // Generate a permanent token for the bot user only. - // Human user tokens are minted on demand with a short TTL (see createShortLivedUserToken). - const botUserToken = await createUserToken(apiSecret, botUserId); - - return { apiKey, botUserId, botUserToken, channelId }; -} - -/** - * Send a message to a Stream Chat channel on behalf of a user. - * - * Used to programmatically inject messages into a KiloClaw instance's chat - * channel. The message appears as if the user typed it, so the OpenClaw bot - * (listening via the openclaw-channel-streamchat plugin) processes and responds. - * - * @param apiKey Stream Chat API key - * @param apiSecret Stream Chat API secret (used to mint a server JWT) - * @param channelId Target channel ID, e.g. `default-{sandboxId}` - * @param userId The user ID to send the message as (typically the sandboxId) - * @param text Plain-text message content - */ -export async function sendMessage( - apiKey: string, - apiSecret: string, - channelId: string, - userId: string, - text: string -): Promise { - const serverToken = await createServerToken(apiSecret); - - const res = await fetch( - `${STREAM_CHAT_API_BASE}/channels/messaging/${channelId}/message?api_key=${apiKey}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Stream-Auth-Type': 'jwt', - Authorization: serverToken, - }, - body: JSON.stringify({ - message: { text, user_id: userId }, - }), - } - ); - - if (!res.ok) { - const body = await res.text().catch(() => '(unreadable)'); - throw Object.assign(new Error(`Stream Chat sendMessage failed (${res.status}): ${body}`), { - status: res.status, - }); - } -} diff --git a/services/kiloclaw/src/types.ts b/services/kiloclaw/src/types.ts index a235877de6..e6fc74eab7 100644 --- a/services/kiloclaw/src/types.ts +++ b/services/kiloclaw/src/types.ts @@ -76,10 +76,6 @@ export type KiloClawEnv = { // Developer identity (development only, auto-populated by dev-start from `fly auth whoami`) DEV_CREATOR?: string; - // Stream Chat (default channel for new instances) - STREAM_CHAT_API_KEY?: string; - STREAM_CHAT_API_SECRET?: string; - // OpenClaw gateway configuration OPENCLAW_ALLOWED_ORIGINS?: string; KILOCLAW_CHECKIN_URL?: string; diff --git a/services/kiloclaw/worker-configuration.d.ts b/services/kiloclaw/worker-configuration.d.ts index b315fcdf88..b4e9784cff 100644 --- a/services/kiloclaw/worker-configuration.d.ts +++ b/services/kiloclaw/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3a95699adda0e86ca43ba58928903a17) +// Generated by Wrangler by running `wrangler types` (hash: bf2dc9695d3fc36b376b6ca04e7fee27) // Runtime types generated with workerd@1.20260312.1 2025-05-06 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -12,47 +12,44 @@ declare namespace Cloudflare { KILOCLAW_AE: AnalyticsEngineDataset; KILOCLAW_CONTROLLER_AE: AnalyticsEngineDataset; SNAPSHOT_RESTORE_QUEUE: Queue; - KILOCHAT_BASE_URL: "https://chat.kiloapps.io"; + NF_TEAM_ID: "kilo-prod"; + NF_REGION: "us-central"; + NF_DEPLOYMENT_PLAN: "nf-compute-200"; + NF_EDGE_HEADER_NAME: "x-kiloclaw-northflank-edge-prod"; + NF_IMAGE_PATH_TEMPLATE: "ghcr.io/kilo-org/kiloclaw:{tag}"; + NF_IMAGE_CREDENTIALS_ID: "kiloclaw"; REQUIRE_PROXY_TOKEN: "true"; PROACTIVE_REFRESH_THRESHOLD_HOURS: "72"; NEXTAUTH_SECRET: string; - KILOCLAW_INTERNAL_API_SECRET: string; + INTERNAL_API_SECRET: string; GATEWAY_TOKEN_SECRET: string; WORKER_ENV: string; - KILOCODE_API_BASE_URL: string; - FLY_REGISTRY_APP: string; - FLY_ORG_SLUG: string; + BACKEND_API_URL: string; FLY_API_TOKEN: string; - FLY_APP_NAME: string; + FLY_ORG_SLUG: string; + FLY_REGISTRY_APP: string; FLY_REGION: string; + FLY_IMAGE_TAG: string; + OPENCLAW_VERSION: string; + FLY_APP_NAME: string; OPENCLAW_ALLOWED_ORIGINS: string; - AGENT_ENV_VARS_PRIVATE_KEY: string; - DEV_CREATOR: string; - BACKEND_API_URL: string; + NEXT_PUBLIC_POSTHOG_KEY: string; STREAM_CHAT_API_KEY: string; STREAM_CHAT_API_SECRET: string; + KILOCHAT_API_TOKEN: string; + KILOCHAT_WEBHOOK_SECRET: string; + KILOCHAT_BASE_URL: string; + KILOCLAW_DEFAULT_PROVIDER: string; KILOCLAW_CHECKIN_URL: string; - NEXT_PUBLIC_POSTHOG_KEY: string; - FLY_IMAGE_TAG: string; + KILOCODE_API_BASE_URL: string; FLY_IMAGE_DIGEST: string; - OPENCLAW_VERSION: string; FLY_IMAGE_CONTENT_HASH: string; + KILOCLAW_INTERNAL_API_SECRET: string; DOCKER_LOCAL_API_BASE: string; DOCKER_LOCAL_IMAGE: string; DOCKER_LOCAL_PORT_RANGE: string; - NF_API_TOKEN: string; - NF_API_BASE: string; - NF_TEAM_ID: string; - NF_REGION: string; - NF_DEPLOYMENT_PLAN: string; - NF_STORAGE_CLASS_NAME: string; - NF_STORAGE_ACCESS_MODE: string; - NF_VOLUME_SIZE_MB: string; - NF_EPHEMERAL_STORAGE_MB: string; - NF_EDGE_HEADER_NAME: string; - NF_EDGE_HEADER_VALUE: string; - NF_IMAGE_PATH_TEMPLATE: string; - NF_IMAGE_CREDENTIALS_ID: string; + DEV_CREATOR: string; + GOOGLE_WORKSPACE_OAUTH_REDIRECT_URI: string; KILOCLAW_INSTANCE: DurableObjectNamespace; KILOCLAW_APP: DurableObjectNamespace; KILOCLAW_REGISTRY: DurableObjectNamespace; @@ -65,7 +62,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types From 99470bbcc3a1097151dc04d2080072f8e43da2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 15:55:52 +0200 Subject: [PATCH 018/289] =?UTF-8?q?feat:=20PR=207=20=E2=80=94=20drop=20bad?= =?UTF-8?q?ge=5Fcounts=20table;=20per-user=20DO=20storage;=20JWT-authed=20?= =?UTF-8?q?badge=20endpoints=20(#2961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers * feat(db): migration to rename badge_counts and reset rows * feat(notifications): add badge-bucket key builders The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts. * chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET * chore(notifications): add vitest scaffold * feat(notifications): rewrite NotificationChannelDO around dispatchPush * chore(notifications): drop orphan badgeBucketForInstance helper * feat(notifications): add sendPushForConversation WorkerEntrypoint RPC * chore(notifications): delete Stream webhook route * chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types * feat(event-service): add kiloclaw event-context helpers; migrate kilo-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth. * feat(kilo-chat): add fetchSandboxLabel helper * chore(kilo-chat): add NOTIFICATIONS service binding * feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is " · "; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db). * chore(notifications): drop orphan stream-chat dep, refresh worker types, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter). * fix(notifications): named entrypoint export, retry-safe badge, alarm-leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths. * fix(notifications): close two cleanup-alarm leaks - Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call. * refactor(event-service): compose presence contexts from kiloclaw helpers The kiloclaw-scoped presence paths are literally `/presence` prefixed onto the kiloclaw event-context paths. Build them by composition so the `/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in exactly one place — `kiloclaw-contexts.ts`. Pure refactor; same string output, template-literal types still narrow to the same shape. * feat(web): add kiloChat.getToken tRPC procedure * refactor(web): use kiloclaw-context helpers for event subscriptions * feat(web): lift EventServiceClient to global provider Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter. * feat(web): add usePresenceSubscription primitive Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators. * refactor(web): collapse kilo-chat event subscriptions into usePresenceSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts. * feat(web): subscribe to /presence/web while tab is visible * feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views * refactor(web): extract useDocumentVisible primitive * feat(web): subscribe to conversation presence while tab visible * style(web): reflow useDocumentVisible useState init to one line * refactor(web): tighten presence hook + kilo-chat router contract - usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription * fix(event-service): refcount subscribe/unsubscribe by context Multiple consumers can now independently hold the same context without trampling each other. The wire context.subscribe/context.unsubscribe messages are only sent on the 0->1 and 1->0 refcount transitions; the intermediate churn stays client-side. Resubscribe-on-reconnect dedupes by context key. Tests cover: double-subscribe collapses to a single wire send, partial unsubscribe keeps the context alive, last-consumer-out releases it, mixed batches only send newly-active contexts, unknown-context unsubscribes are no-ops, and reconnect resubscribes each context once. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL * chore(mobile): add kilo-chat workspace deps * feat(mobile): add kilo-chat token getter with caching * feat(mobile): add useCurrentUserId from JWT sub * feat(mobile): add KiloChatProvider * feat(mobile): add useKiloChatClient and useEventServiceClient hooks * fix(mobile): fix lint errors in kilo-chat token getter * fix(mobile): fix lint errors in useCurrentUserId hook * fix(mobile): fix lint errors in useKiloChatClient hook * feat(mobile): mount KiloChatProvider in (app) layout * fix(kilo-chat): assert non-null in base64urlEncode loop * fix(mobile): share kilo-chat token cache + handle fetch errors Hoist cache and in-flight promise refs to module scope so all useKiloChatTokenGetter() instances (provider + useCurrentUserId) share one cache instead of each maintaining an independent one. Wrap the fetch in try/catch/finally: on error rejectShared() is called so concurrent waiters fail fast instead of hanging forever, and inFlight is always cleared in finally regardless of outcome. * fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId - Key the module-level kilo-chat JWT cache and in-flight ref on the current auth token, so signing out and back in as a different user within the 1h token window no longer returns the previous user's cached JWT. - Restructure dedup so the first caller awaits the same shared promise via a slot reference, eliminating the unhandled rejection that the prior resolve/reject-pair pattern produced when the only caller's fetch failed. - Decode kiloUserId from the JWT payload instead of the standard `sub` claim — generateApiToken writes the user id as kiloUserId, so the sub-based version always returned null. * fix(mobile): read auth token at call time, not at hook render KiloChatProvider builds its EventService and KiloChat clients exactly once via useState initializer, so it captures whatever getter exists at first mount. Closing the previous getter over a render-time `authToken` meant a cold start where the (app) layout mounted before SecureStore finished loading would freeze the clients with an undefined token, trapping them in a permanent reconnect loop. Read the auth token from SecureStore inside the getter, the same pattern trpcClient uses. The hook returns a stable callback with no React deps, and the cache stays keyed on the auth token so user-switch safety is preserved. * feat(mobile): add usePresenceSubscription primitive * feat(mobile): subscribe to /presence/app while app is active * feat(mobile): add useInstancePresence hook * feat(mobile): add useConversationPresence hook * fix(mobile): fix lint errors in presence hooks * feat(mobile): add useEventSubscription primitive * feat(mobile): add useInstanceEventSubscription * fix(mobile): apply curly/switch-case-braces lint rules to event hooks * feat(kilo-chat-hooks): create shared package; extract useConversations * feat(kilo-chat-hooks): extract useMessages — base query + optimistic send Move PAGE_SIZE, helper functions (applyReactionAdded/Removed, restoreMessageInCache, removeMessageFromCache, findMessageInCache), useMessages infinite-query hook, and useSendMessage mutation into @kilocode/kilo-chat-hooks. Web's useMessages.ts re-exports the moved hooks and retains local helper copies for remaining mutations (37b will collapse). * feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations * feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared package Moves the live event-stream cache patcher from the web-only useMessages file into @kilocode/kilo-chat-hooks. Adds an optional onActionFailed callback so platform wrappers inject toasts; web passes toast.error. * feat(mobile): wire shared kilo-chat-hooks + platform adapters * fix(kilo-chat-hooks): centralize query keys; tighten event-subscription API - Add packages/kilo-chat-hooks/src/query-keys.ts with conversations/ conversation/messages/bot-status helpers; route every hook + invalidator through it. Fixes the mobile useInstanceEventSubscription bug where invalidations used ['conversations', sandboxId] but the queries register under ['kilo-chat', 'conversations', sandboxId], so list previews and unread counts never refreshed on incoming events. - useEventSubscription now takes a single event name; callers register one hook per event. Drops the events.join('|') dependency hack and the eslint-disable. useInstanceEventSubscription becomes six explicit registrations. - Drop the hardcoded English toast string from useMessageCacheUpdater; onActionFailed is () => void and the message lives at each call site. - Extract useAppActiveAndFocused to deduplicate AppState+focus boilerplate shared by useInstancePresence and useConversationPresence. * fix(mobile): subscribe to conversation.* events on instance context The instance-level subscription was listening for message.created/updated/ deleted, which are published on conversation contexts and never fire here. Replace them with conversation.renamed, conversation.read, and conversation.activity — the events kilo-chat actually pushes to the instance context — so list updates (title, unread, last-activity) invalidate the conversations query as intended. * chore(mobile): add @shopify/flash-list dependency Required by the kilo-chat MessageList and ConversationListScreen components. * chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL These were declared in env-keys.js by PR 5a but never added to apps/mobile/.env, which broke the dev build. * feat(mobile): add EmptyConversationList * feat(mobile): add ConversationHeader * feat(mobile): add TypingIndicator placeholder * feat(mobile): add MessageInput * feat(mobile): add MessageBubble * feat(mobile): add MessageList Implement MessageList using FlashList v2 with maintainVisibleContentPosition and startRenderingFromBottom for chat layout; wire fetchOlder via onStartReached. * feat(mobile): add ConversationScreen * feat(mobile): add ConversationListScreen * fix(mobile): address review feedback on kilo-chat components - Drop double-cast `as unknown as Href` in favor of `as Href` - Use themed `Text` from `@/components/ui/text` and local `useKiloChatClient` re-export in `MessageBubble` - Switch `crypto.randomUUID()` to `expo-crypto`'s `Crypto.randomUUID` to match existing usage in `cloud-agent-runtime.ts` * feat(mobile): add chat sandbox stack layout * feat(mobile): add conversation list route * feat(mobile): add conversation message route * feat(mobile): wire chat deep links and active-conversation suppression * fix(mobile): clear correct badge bucket on legacy chat foreground push * chore(mobile): delete Stream-based chat components and routes * chore(mobile): remove useStreamChatCredentials hook * chore: remove stream-chat deps and RN patch * chore(web): remove Stream tRPC procedures * chore(web): delete Stream chat-credentials API route * chore(web): strip Stream methods from kiloclaw clients * chore(web): replace ChatTab with redirect, drop Stream hooks * chore(kiloclaw): delete src/stream-chat directory * chore(kiloclaw): remove Stream injections from instance DO and routes * chore(kiloclaw): remove Stream from controller config-writer * chore(kiloclaw): drop STREAM_CHAT_* secret bindings * chore(web): remove residual Stream CSS and npm deps * chore(mobile): drop unused exports and deps flagged by knip * refactor(notifications): re-key DO per-user, move badge state to DO storage Key NotificationChannelDO by recipient userId instead of conversationId, and store per-bucket badge counts directly in DO storage under `bucket:${badgeBucket}` keys. The Drizzle `badge_counts` insert/sum paths are gone from the DO; sendPushForConversationCore now fans out to one DO per recipient via idFromName(userId). Adds private incrementBucket / getTotal helpers and public markBucketRead / listNonZeroBuckets RPC for the upcoming HTTP routes. * feat(notifications): JWT auth + badge HTTP routes Mirrors kilo-chat's auth middleware: bearer Kilo JWT verified against NEXTAUTH_SECRET, callerId/callerKind set on context. Mounts CORS + auth on /v1/* and adds GET /v1/badges + POST /v1/badges/mark-read backed by NotificationChannelDO RPC methods. * fix(notifications): mount useWorkersLogger so auth setTags is effective Without the middleware, logger.setTags in authMiddleware writes to no AsyncLocalStorage frame. Mirrors the kilo-chat setup. Also tightens the mark-read missing-bucket test to lock the JSON error contract for mobile. * refactor(web): drop badge_counts tRPC procedures Remove markChatRead and getUnreadCounts from user router; mobile now calls the notifications worker HTTP routes (GET /v1/badges, POST /v1/badges/mark-read) added in tasks 63-64. The badge_counts table itself is dropped in a follow-up. * feat(mobile): call notifications worker for badge counts Replace tRPC `user.getUnreadCounts` and `user.markChatRead` (deleted in prior commit) with direct fetches to the notifications worker (`GET /v1/badges`, `POST /v1/badges/mark-read`) authed with the existing kilo-chat JWT. Adds `EXPO_PUBLIC_NOTIFICATIONS_URL` config and rekeys the unread-counts query to `['badges', userId]`. * refactor(db): drop badge_counts table Badge state now lives in the notifications DO storage; the postgres table is no longer read or written by any service. * chore(db): revert incidental NewSecurityAdvisorScan reorder The previous commit also moved NewSecurityAdvisorScan up next to SecurityAdvisorScan as a cosmetic cleanup; that's out of scope for the badge_counts removal. Restore the orphan to its original spot at end-of-file so the badge_counts diff is minimal. * docs(notifications): update badge-bucket comment after table drop * chore: update env vars * chore(mobile): drop expo public env prefix * chore(kilo-chat): remove redundant non-null assertion * fix(mobile): clear badge cache on mark read --- apps/mobile/.env | 5 +- .../kilo-chat/hooks/use-mark-read.ts | 80 +- apps/mobile/src/lib/config.ts | 1 + apps/mobile/src/lib/env-keys.js | 5 +- .../hooks/use-unread-counts-invalidation.ts | 16 +- .../mobile/src/lib/hooks/use-unread-counts.ts | 40 +- apps/web/src/routers/user-router.test.ts | 26 +- apps/web/src/routers/user-router.ts | 44 +- .../src/migrations/0108_drop_badge_counts.sql | 1 + .../db/src/migrations/meta/0108_snapshot.json | 17955 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 26 - packages/notifications/src/badge-buckets.ts | 8 +- pnpm-lock.yaml | 41 +- services/notifications/package.json | 1 + .../notifications/src/__tests__/auth.test.ts | 89 + .../src/__tests__/badge-storage.test.ts | 90 + .../src/__tests__/dispatch-push.test.ts | 136 +- .../src/__tests__/routes-badges.test.ts | 135 + .../send-push-for-conversation.test.ts | 8 +- services/notifications/src/__tests__/setup.ts | 1 - services/notifications/src/auth.ts | 36 + .../src/dos/NotificationChannelDO.ts | 75 +- services/notifications/src/index.ts | 55 +- services/notifications/src/util/logger.ts | 23 + .../notifications/worker-configuration.d.ts | 3 +- services/notifications/wrangler.jsonc | 5 + 27 files changed, 18694 insertions(+), 218 deletions(-) create mode 100644 packages/db/src/migrations/0108_drop_badge_counts.sql create mode 100644 packages/db/src/migrations/meta/0108_snapshot.json create mode 100644 services/notifications/src/__tests__/auth.test.ts create mode 100644 services/notifications/src/__tests__/badge-storage.test.ts create mode 100644 services/notifications/src/__tests__/routes-badges.test.ts create mode 100644 services/notifications/src/auth.ts create mode 100644 services/notifications/src/util/logger.ts diff --git a/apps/mobile/.env b/apps/mobile/.env index 5882750319..345fc7d139 100644 --- a/apps/mobile/.env +++ b/apps/mobile/.env @@ -5,5 +5,6 @@ CLOUD_AGENT_WS_URL=wss://cloud-agent-next.kilosessions.ai SESSION_INGEST_WS_URL=wss://ingest.kilosessions.ai APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 APPSFLYER_APP_ID=6761193135 -EXPO_PUBLIC_KILO_CHAT_URL=https://kilo-chat.kilosessions.ai -EXPO_PUBLIC_EVENT_SERVICE_URL=wss://event-service.kilosessions.ai +KILO_CHAT_URL=https://chat.kiloapps.io +EVENT_SERVICE_URL=wss://events.kiloapps.io +NOTIFICATIONS_URL=https://notifications.kiloapps.io diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index 4123d96972..be54547dbf 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -1,28 +1,78 @@ import { useCallback } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as Notifications from 'expo-notifications'; +import { toast } from 'sonner-native'; import { badgeBucketForConversation } from '@kilocode/notifications'; -import { useTRPC } from '@/lib/trpc'; +import { NOTIFICATIONS_URL } from '@/lib/config'; + +import { useCurrentUserId } from './use-current-user-id'; +import { useKiloChatTokenGetter } from './use-kilo-chat-token'; + +type BadgeBucket = { badgeBucket: string; badgeCount: number }; +type MarkReadContext = { + previousBadges?: BadgeBucket[]; + queryKey?: readonly ['badges', string]; +}; export function useMarkRead() { - const trpc = useTRPC(); - const mutation = useMutation( - trpc.user.markChatRead.mutationOptions({ - onSuccess: result => { - if (typeof result.badgeCount === 'number') { - void Notifications.setBadgeCountAsync(result.badgeCount); - } - }, - }) - ); + const queryClient = useQueryClient(); + const userId = useCurrentUserId(); + const getToken = useKiloChatTokenGetter(); + + const mutation = useMutation({ + mutationFn: async (badgeBucket: string): Promise<{ badgeCount: number }> => { + const token = await getToken(); + const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ badgeBucket }), + }); + if (!response.ok) { + throw new Error(`Failed to mark badge read: ${response.status}`); + } + return (await response.json()) as { badgeCount: number }; + }, + onMutate: async (badgeBucket): Promise => { + if (userId === null) { + return {}; + } + + const queryKey = ['badges', userId] as const; + await queryClient.cancelQueries({ queryKey }); + const previousBadges = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, badges => + badges?.filter(row => row.badgeBucket !== badgeBucket) + ); + + return { previousBadges, queryKey }; + }, + onError: (error, _badgeBucket, context) => { + if (context?.queryKey && context.previousBadges) { + queryClient.setQueryData(context.queryKey, context.previousBadges); + } + toast.error(error.message); + }, + onSuccess: result => { + if (typeof result.badgeCount === 'number') { + void Notifications.setBadgeCountAsync(result.badgeCount); + } + }, + onSettled: () => { + if (userId !== null) { + void queryClient.invalidateQueries({ queryKey: ['badges', userId] }); + } + }, + }); return useCallback( (sandboxId: string, conversationId: string) => { - mutation.mutate({ - badgeBucket: badgeBucketForConversation(sandboxId, conversationId), - }); + mutation.mutate(badgeBucketForConversation(sandboxId, conversationId)); }, [mutation] ); diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index be543127f8..90f83306a7 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -21,3 +21,4 @@ export const SESSION_INGEST_WS_URL: string = required('sessionIngestWsUrl'); export const KILO_CHAT_URL: string = required('kiloChatUrl'); export const EVENT_SERVICE_URL: string = required('eventServiceUrl'); +export const NOTIFICATIONS_URL: string = required('notificationsUrl'); diff --git a/apps/mobile/src/lib/env-keys.js b/apps/mobile/src/lib/env-keys.js index 3200f2ecda..0cb0bca167 100644 --- a/apps/mobile/src/lib/env-keys.js +++ b/apps/mobile/src/lib/env-keys.js @@ -7,6 +7,7 @@ export const ENV_KEYS = { sessionIngestWsUrl: 'SESSION_INGEST_WS_URL', appsFlyerDevKey: 'APPSFLYER_DEV_KEY', appsFlyerAppId: 'APPSFLYER_APP_ID', - kiloChatUrl: 'EXPO_PUBLIC_KILO_CHAT_URL', - eventServiceUrl: 'EXPO_PUBLIC_EVENT_SERVICE_URL', + kiloChatUrl: 'KILO_CHAT_URL', + eventServiceUrl: 'EVENT_SERVICE_URL', + notificationsUrl: 'NOTIFICATIONS_URL', }; diff --git a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts index 4d9846244e..e94bb43b33 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts @@ -3,11 +3,11 @@ import * as Notifications from 'expo-notifications'; import { useEffect } from 'react'; import { AppState } from 'react-native'; +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; import { parseNotificationData } from '@/lib/notifications'; -import { useTRPC } from '@/lib/trpc'; /** - * Keeps the `user.getUnreadCounts` cache in sync with real-time notification + * Keeps the `['badges', userId]` cache in sync with real-time notification * traffic so per-instance badges on the dashboard reflect pushes received while * the app is open or resumed from background. * @@ -20,14 +20,16 @@ import { useTRPC } from '@/lib/trpc'; */ export function useUnreadCountsInvalidation() { const queryClient = useQueryClient(); - const trpc = useTRPC(); + const userId = useCurrentUserId(); useEffect(() => { - // `trpc` is stable (memoized inside TRPCProvider) but `queryKey()` returns - // a fresh array on each call, so we resolve it inside each invalidation. + if (userId === null) { + return undefined; + } + const invalidate = () => { void queryClient.invalidateQueries({ - queryKey: trpc.user.getUnreadCounts.queryKey(), + queryKey: ['badges', userId], }); }; @@ -48,5 +50,5 @@ export function useUnreadCountsInvalidation() { received.remove(); appStateSubscription.remove(); }; - }, [queryClient, trpc]); + }, [queryClient, userId]); } diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index 04b28d5db5..e07f0ffd17 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,26 +1,44 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token'; import { badgeBucketForInstance } from '@/lib/badge-buckets'; -import { useTRPC } from '@/lib/trpc'; +import { NOTIFICATIONS_URL } from '@/lib/config'; + +type Bucket = { badgeBucket: string; badgeCount: number }; /** - * Fetches unread message counts for the current user and returns a Map keyed by - * instance badge bucket for O(1) lookup from dashboard cards. Conversation - * buckets are summed into their parent instance bucket. + * Fetches unread message counts for the current user from the notifications + * worker and returns a Map keyed by instance badge bucket for O(1) lookup from + * dashboard cards. Conversation buckets are summed into their parent instance + * bucket. * * Freshness is driven by invalidations, not polling: * - Foreground chat push → invalidate (see `use-unread-counts-invalidation`). * - App returns to active → invalidate. - * - `markChatRead` optimistically clears the relevant row. + * - `useMarkRead` optimistically clears the relevant row. */ export function useUnreadCounts() { - const trpc = useTRPC(); - const query = useQuery( - trpc.user.getUnreadCounts.queryOptions(undefined, { - staleTime: 30_000, - }) - ); + const userId = useCurrentUserId(); + const getToken = useKiloChatTokenGetter(); + + const query = useQuery({ + queryKey: ['badges', userId], + enabled: userId !== null, + staleTime: 30_000, + queryFn: async () => { + const token = await getToken(); + const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch badges: ${response.status}`); + } + const body = (await response.json()) as { buckets: Bucket[] }; + return body.buckets; + }, + }); const byBadgeBucket = useMemo(() => { const map = new Map(); diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 8ecf8baade..5585567d49 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -1,7 +1,7 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { badge_counts, kilocode_users } from '@kilocode/db/schema'; -import { eq, inArray } from 'drizzle-orm'; +import { kilocode_users } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import type { User } from '@kilocode/db/schema'; @@ -416,25 +416,3 @@ describe('session and API token reset mutations', () => { expect(updated.api_token_pepper).toBe('api-pepper-before'); }); }); - -describe('user router - getUnreadCounts', () => { - it('does not return counts from other users', async () => { - const user = await insertTestUser({ - google_user_email: `unread-counts-me-${crypto.randomUUID()}@example.com`, - }); - const other = await insertTestUser({ - google_user_email: `unread-counts-other-${crypto.randomUUID()}@example.com`, - }); - await db.insert(badge_counts).values([ - { user_id: user.id, badge_bucket: 'sandbox-mine', badge_count: 4 }, - { user_id: other.id, badge_bucket: 'sandbox-theirs', badge_count: 9 }, - ]); - - const caller = await createCallerForUser(user.id); - const result = await caller.user.getUnreadCounts(); - - expect(result).toEqual([{ badgeBucket: 'sandbox-mine', badgeCount: 4 }]); - - await db.delete(badge_counts).where(inArray(badge_counts.user_id, [user.id, other.id])); - }); -}); diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index 4d41e9623a..f6eb3ba0d6 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -20,9 +20,8 @@ import { kiloclaw_instances, kiloclaw_subscriptions, user_push_tokens, - badge_counts, } from '@kilocode/db/schema'; -import { eq, and, isNull, inArray, sql, gt, gte, sum } from 'drizzle-orm'; +import { eq, and, isNull, inArray, sql, gte } from 'drizzle-orm'; import crypto from 'crypto'; import { checkDiscordGuildMembership } from '@/lib/integrations/discord-guild-membership'; import { AuthProviderIdSchema } from '@/lib/auth/provider-metadata'; @@ -722,45 +721,4 @@ export const userRouter = createTRPCRouter({ .from(user_push_tokens) .where(eq(user_push_tokens.user_id, ctx.user.id)); }), - - // ─── Badge Counts ────────────────────────────────────────────────── - - // Called by the mobile app when the user opens a chat. Resets the badge - // count for that channel to 0 and returns the new total across all - // channels, which the app applies as the OS badge count. - markChatRead: baseProcedure - .input(z.object({ badgeBucket: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - await db - .update(badge_counts) - .set({ badge_count: 0 }) - .where( - and( - eq(badge_counts.user_id, ctx.user.id), - eq(badge_counts.badge_bucket, input.badgeBucket) - ) - ); - - const [totals] = await db - .select({ total: sum(badge_counts.badge_count) }) - .from(badge_counts) - .where(eq(badge_counts.user_id, ctx.user.id)); - - return { badgeCount: Number(totals?.total ?? 0) }; - }), - - // Per-channel unread counts for showing badges on the mobile dashboard. For - // kiloclaw chats, `channelId` equals `sandbox_id` (see NotificationChannelDO). - // Destroyed instances are filtered implicitly on the client — the dashboard only - // renders cards for instances returned by `listAllInstances`, which already - // excludes destroyed ones. - getUnreadCounts: baseProcedure.query(async ({ ctx }) => { - return readDb - .select({ - badgeBucket: badge_counts.badge_bucket, - badgeCount: badge_counts.badge_count, - }) - .from(badge_counts) - .where(and(eq(badge_counts.user_id, ctx.user.id), gt(badge_counts.badge_count, 0))); - }), }); diff --git a/packages/db/src/migrations/0108_drop_badge_counts.sql b/packages/db/src/migrations/0108_drop_badge_counts.sql new file mode 100644 index 0000000000..df38dc792a --- /dev/null +++ b/packages/db/src/migrations/0108_drop_badge_counts.sql @@ -0,0 +1 @@ +DROP TABLE "badge_counts" CASCADE; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0108_snapshot.json b/packages/db/src/migrations/meta/0108_snapshot.json new file mode 100644 index 0000000000..b24f83823b --- /dev/null +++ b/packages/db/src/migrations/meta/0108_snapshot.json @@ -0,0 +1,17955 @@ +{ + "id": "884639ba-07b1-49ac-a684-fc77d415feba", + "prevId": "5b345e9c-de70-4377-a842-4747d863b166", + "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_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_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_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 + }, + "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": {} + } + }, + "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.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_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 + }, + "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": "'v1'" + } + }, + "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": {} + } + }, + "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": false + }, + "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 + }, + "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_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 + }, + "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 + }, + "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": {} + } + }, + "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_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 + }, + "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'" + }, + "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 + }, + "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_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_repo_pr_sha", + "columns": [ + { + "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, + "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 + }, + "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_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.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_file_hash": { + "name": "IDX_code_indexing_manifest_file_hash", + "columns": [ + { + "expression": "file_hash", + "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.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 + }, + "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": {}, + "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.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.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 + }, + "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": {} + } + }, + "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.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 + }, + "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": {} + }, + "idx_free_model_usage_user_created_at": { + "name": "idx_free_model_usage_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, + "where": "\"free_model_usage\".\"kilo_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.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', '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', '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')" + } + }, + "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 + }, + "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', 'cron')" + } + }, + "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_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 + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_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": {} + } + }, + "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_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.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 + }, + "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": { + "name": "UQ_kiloclaw_email_log_user_instance_type", + "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" + } + ], + "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 + } + }, + "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": {} + } + }, + "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": {}, + "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 + }, + "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_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_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_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 + }, + "is_admin": { + "name": "is_admin", + "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 + }, + "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 + } + }, + "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_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": { + "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" + } + }, + "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()" + } + }, + "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": {} + } + }, + "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.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_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 + } + }, + "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": {} + } + }, + "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.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.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_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 + }, + "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": {}, + "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_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 + }, + "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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + } + }, + "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_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 + }, + "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": {} + }, + "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.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_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 + }, + "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 + }, + "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": {} + } + }, + "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.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" + } + }, + "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": { + "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": { + "uq_security_findings_source": { + "name": "uq_security_findings_source", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "source", + "source_id" + ] + } + }, + "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.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.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_fingerprint_data": { + "name": "idx_fingerprint_data", + "columns": [ + { + "expression": "fingerprint_data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_reasons": { + "name": "idx_reasons", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "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.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_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 + } + }, + "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 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 145070107c..7db0045126 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -757,6 +757,13 @@ "when": 1777476540389, "tag": "0107_dapper_power_pack", "breakpoints": true + }, + { + "idx": 108, + "version": "7", + "when": 1777554058793, + "tag": "0108_drop_badge_counts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 11abc622ea..3d019df4b5 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -4598,30 +4598,4 @@ export const security_advisor_content = pgTable('security_advisor_content', { export type SecurityAdvisorContent = typeof security_advisor_content.$inferSelect; export type NewSecurityAdvisorContent = typeof security_advisor_content.$inferInsert; -// ============ BADGE COUNTS ============ -// Per-user per-bucket unread notification counts for mobile app badge display. -// (user_id, badge_bucket) is the composite PK — one row per user per bucket. -// badge_bucket is a free-form string chosen by the producer (e.g. sandbox_id -// today, conversation id later). The notification service increments badge_count -// on each push and sums across all buckets to get the total badge count to -// include in the push payload. The client resets a bucket's count (to 0) when -// the user views that item. - -export const badge_counts = pgTable( - 'badge_counts', - { - user_id: text() - .notNull() - .references(() => kilocode_users.id, { onDelete: 'cascade' }), - badge_bucket: text().notNull(), - badge_count: integer().notNull().default(0), - updated_at: timestamp({ withTimezone: true, mode: 'string' }) - .defaultNow() - .notNull() - .$onUpdateFn(() => sql`now()`), - }, - table => [primaryKey({ columns: [table.user_id, table.badge_bucket] })] -); - -export type BadgeCount = typeof badge_counts.$inferSelect; export type NewSecurityAdvisorScan = typeof security_advisor_scans.$inferInsert; diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts index 56917b3d34..90259ed1e2 100644 --- a/packages/notifications/src/badge-buckets.ts +++ b/packages/notifications/src/badge-buckets.ts @@ -1,8 +1,8 @@ /** - * Badge-bucket key builders. The `badge_counts` table uses a free-form - * `badge_bucket` string as part of its composite PK; producers of unread - * counts MUST derive their bucket key via these helpers so namespaces - * don't collide as more surfaces start emitting badge updates. + * Badge-bucket key builders. Per-user badge state lives in `NotificationChannelDO` + * storage under `bucket:${badgeBucket}`; producers of unread counts MUST derive + * their bucket key via these helpers so namespaces don't collide as more surfaces + * start emitting badge updates. */ export const badgeBucketForConversation = (sandboxId: string, conversationId: string) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fd5cf94ec..43651907b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1577,7 +1577,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2048,6 +2048,9 @@ importers: hono: specifier: ^4.12.7 version: 4.12.8 + workers-tagged-logger: + specifier: 'catalog:' + version: 1.0.0 zod: specifier: 'catalog:' version: 4.3.6 @@ -16437,7 +16440,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.4 miniflare: 4.20260310.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) wrangler: 4.72.0(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -21862,7 +21865,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -25251,6 +25254,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -25902,6 +25924,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} diff --git a/services/notifications/package.json b/services/notifications/package.json index a304e241c6..c0f3d71110 100644 --- a/services/notifications/package.json +++ b/services/notifications/package.json @@ -28,6 +28,7 @@ "drizzle-orm": "catalog:", "expo-server-sdk": "^6.1.0", "hono": "catalog:", + "workers-tagged-logger": "catalog:", "zod": "catalog:" } } diff --git a/services/notifications/src/__tests__/auth.test.ts b/services/notifications/src/__tests__/auth.test.ts new file mode 100644 index 0000000000..66c5634521 --- /dev/null +++ b/services/notifications/src/__tests__/auth.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { signKiloToken } from '@kilocode/worker-utils'; +import { authMiddleware } from '../auth'; +import type { AuthContext } from '../auth'; + +type MockEnv = { + NEXTAUTH_SECRET: { get: () => Promise }; +}; + +const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; + +function makeApp(_env: MockEnv) { + const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>(); + app.use('*', authMiddleware); + app.get('/test', c => c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') })); + return app; +} + +const defaultEnv: MockEnv = { + NEXTAUTH_SECRET: { get: async () => TEST_JWT_SECRET }, +}; + +describe('authMiddleware', () => { + it('returns 401 with no authorization header', async () => { + const res = await makeApp(defaultEnv).request('/test', {}, defaultEnv); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('authenticates with a valid JWT and sets user identity', async () => { + const { token } = await signKiloToken({ + userId: 'user-xyz-789', + pepper: null, + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + }); + const res = await makeApp(defaultEnv).request( + '/test', + { headers: { authorization: `Bearer ${token}` } }, + defaultEnv + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + callerId: 'user-xyz-789', + callerKind: 'user', + }); + }); + + it('returns 401 with an expired JWT', async () => { + const { token } = await signKiloToken({ + userId: 'user-xyz-789', + pepper: null, + secret: TEST_JWT_SECRET, + expiresInSeconds: -1, + }); + const res = await makeApp(defaultEnv).request( + '/test', + { headers: { authorization: `Bearer ${token}` } }, + defaultEnv + ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 401 for an arbitrary non-JWT bearer', async () => { + const res = await makeApp(defaultEnv).request( + '/test', + { headers: { authorization: 'Bearer not-a-jwt' } }, + defaultEnv + ); + expect(res.status).toBe(401); + }); + + it('returns 401 when the JWT is signed with a different secret', async () => { + const { token } = await signKiloToken({ + userId: 'user-xyz-789', + pepper: null, + secret: 'a-completely-different-secret-of-correct-length', + expiresInSeconds: 3600, + }); + const res = await makeApp(defaultEnv).request( + '/test', + { headers: { authorization: `Bearer ${token}` } }, + defaultEnv + ); + expect(res.status).toBe(401); + }); +}); diff --git a/services/notifications/src/__tests__/badge-storage.test.ts b/services/notifications/src/__tests__/badge-storage.test.ts new file mode 100644 index 0000000000..e0cc581b3e --- /dev/null +++ b/services/notifications/src/__tests__/badge-storage.test.ts @@ -0,0 +1,90 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; + +import type * as do_module from '../dos/NotificationChannelDO'; + +type DOStub = DurableObjectStub & { + markBucketRead: (bucket: string) => Promise; + listNonZeroBuckets: () => Promise<{ badgeBucket: string; badgeCount: number }[]>; +}; + +function getDO(name: string): DOStub { + const id = env.NOTIFICATION_CHANNEL_DO.idFromName(name); + return env.NOTIFICATION_CHANNEL_DO.get(id) as unknown as DOStub; +} + +// Seed buckets directly into DO storage so these tests stay focused on the +// helper round-trips rather than exercising dispatchPush again. +async function seedBuckets(stub: DOStub, buckets: Record) { + await runInDurableObject(stub, async (_inst, state) => { + for (const [bucket, count] of Object.entries(buckets)) { + await state.storage.put(`bucket:${bucket}`, count); + } + }); +} + +describe('NotificationChannelDO badge storage helpers', () => { + it('listNonZeroBuckets returns nothing on a fresh DO', async () => { + const stub = getDO('user-empty'); + const out = await stub.listNonZeroBuckets(); + expect(out).toEqual([]); + }); + + it('listNonZeroBuckets returns all stored buckets and skips zero counts', async () => { + const stub = getDO('user-list'); + await seedBuckets(stub, { 'kiloclaw:sb1:conv1': 2, 'kiloclaw:sb1:conv2': 5, zeroed: 0 }); + + const out = await stub.listNonZeroBuckets(); + out.sort((a, b) => a.badgeBucket.localeCompare(b.badgeBucket)); + + expect(out).toEqual([ + { badgeBucket: 'kiloclaw:sb1:conv1', badgeCount: 2 }, + { badgeBucket: 'kiloclaw:sb1:conv2', badgeCount: 5 }, + ]); + }); + + it('markBucketRead clears the bucket and returns the user total', async () => { + const stub = getDO('user-mark'); + await seedBuckets(stub, { conv1: 2, conv2: 5 }); + + const totalAfter = await stub.markBucketRead('conv1'); + expect(totalAfter).toBe(5); + + const remaining = await runInDurableObject(stub, async (_inst, state) => { + const entries = await state.storage.list({ prefix: 'bucket:' }); + return Array.from(entries.entries()); + }); + expect(remaining).toEqual([['bucket:conv2', 5]]); + }); + + it('markBucketRead is idempotent and returns the running total', async () => { + const stub = getDO('user-mark-twice'); + await seedBuckets(stub, { conv1: 3, conv2: 1 }); + + expect(await stub.markBucketRead('conv1')).toBe(1); + // Marking the same bucket again should leave state untouched and still + // return the user's current total. + expect(await stub.markBucketRead('conv1')).toBe(1); + // Marking a never-seen bucket is a no-op. + expect(await stub.markBucketRead('does-not-exist')).toBe(1); + }); + + it('round-trips: increment via dispatchPush, list, mark read', async () => { + // Seed two buckets to mimic two conversations the user has unread in. + const stub = getDO('user-roundtrip'); + await seedBuckets(stub, { 'kiloclaw:sb1:c1': 2, 'kiloclaw:sb1:c2': 1 }); + + const before = await stub.listNonZeroBuckets(); + before.sort((a, b) => a.badgeBucket.localeCompare(b.badgeBucket)); + expect(before).toEqual([ + { badgeBucket: 'kiloclaw:sb1:c1', badgeCount: 2 }, + { badgeBucket: 'kiloclaw:sb1:c2', badgeCount: 1 }, + ]); + + const totalAfterRead = await stub.markBucketRead('kiloclaw:sb1:c1'); + expect(totalAfterRead).toBe(1); + + const after = await stub.listNonZeroBuckets(); + expect(after).toEqual([{ badgeBucket: 'kiloclaw:sb1:c2', badgeCount: 1 }]); + }); +}); diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index 1e9dca7fd3..a56268ee17 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -15,7 +15,6 @@ vi.mock('../lib/expo-push', () => ({ type DbState = { tokens: { user_id: string; token: string }[]; - badgeTotal: number; }; function installDbMock(state: DbState) { @@ -26,8 +25,7 @@ function installDbMock(state: DbState) { if (getTableName(table) === 'user_push_tokens') { return state.tokens.map(t => ({ token: t.token })); } - // sum(badge_count) — return single row with `total` - return [{ total: state.badgeTotal }]; + return []; }, }), }), @@ -56,7 +54,7 @@ const baseInput = (over: Partial = {}): DispatchPushInput => ...over, }); -function getDO(name = 'conv1') { +function getDO(name = 'user-1') { const id = env.NOTIFICATION_CHANNEL_DO.idFromName(name); return env.NOTIFICATION_CHANNEL_DO.get(id); } @@ -68,7 +66,7 @@ describe('NotificationChannelDO.dispatchPush', () => { }); it('returns suppressed_presence when EVENT_SERVICE.isUserInContext is true', async () => { - installDbMock({ tokens: [{ user_id: 'user-1', token: 'tok1' }], badgeTotal: 0 }); + installDbMock({ tokens: [{ user_id: 'user-1', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(true); const result = await getDO().dispatchPush(baseInput()); expect(result.kind).toBe('suppressed_presence'); @@ -76,29 +74,67 @@ describe('NotificationChannelDO.dispatchPush', () => { }); it('returns no_tokens when the user has no push tokens', async () => { - installDbMock({ tokens: [], badgeTotal: 0 }); + installDbMock({ tokens: [] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); - const result = await getDO().dispatchPush(baseInput({ userId: 'user-no-tokens' })); + const result = await getDO('user-no-tokens').dispatchPush( + baseInput({ userId: 'user-no-tokens' }) + ); expect(result.kind).toBe('no_tokens'); expect(sendPushNotifications).not.toHaveBeenCalled(); }); - it('delivers, increments badge, writes idempotency key', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + it('delivers, increments bucket in DO storage, writes idempotency key', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); - const result = await getDO('conv-deliver').dispatchPush( - baseInput({ idempotencyKey: 'k-deliver' }) - ); + const stub = getDO('user-deliver'); + + const result = await stub.dispatchPush(baseInput({ idempotencyKey: 'k-deliver' })); + expect(result.kind).toBe('delivered'); expect(sendPushNotifications).toHaveBeenCalledOnce(); const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; expect(messages[0].badge).toBe(1); + + // Bucket persisted to DO storage. + const stored = await runInDurableObject(stub, (_inst, state) => + state.storage.get('bucket:conv1') + ); + expect(stored).toBe(1); + }); + + it('accumulates bucket counts across deliveries and exposes total via badge', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + const stub = getDO('user-accumulate'); + + await stub.dispatchPush(baseInput({ idempotencyKey: 'k-acc-1' })); + await stub.dispatchPush(baseInput({ idempotencyKey: 'k-acc-2' })); + await stub.dispatchPush( + baseInput({ + idempotencyKey: 'k-acc-3', + badge: { badgeBucket: 'conv2', delta: 1 }, + }) + ); + + const calls = vi.mocked(sendPushNotifications).mock.calls; + expect(calls[0]?.[0][0].badge).toBe(1); + expect(calls[1]?.[0][0].badge).toBe(2); + expect(calls[2]?.[0][0].badge).toBe(3); + + const buckets = await runInDurableObject(stub, async (_inst, state) => { + const entries = await state.storage.list({ prefix: 'bucket:' }); + return Array.from(entries.entries()); + }); + expect(buckets.sort()).toEqual([ + ['bucket:conv1', 2], + ['bucket:conv2', 1], + ]); }); it('returns duplicate when the idempotency key has been seen', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); - const stub = getDO('conv-dup'); + const stub = getDO('user-dup'); const input = baseInput({ idempotencyKey: 'k-dup' }); await stub.dispatchPush(input); const second = await stub.dispatchPush(input); @@ -107,21 +143,30 @@ describe('NotificationChannelDO.dispatchPush', () => { }); it('skips badge mutation when badge is null', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); - const result = await getDO('conv-no-badge').dispatchPush( + const stub = getDO('user-no-badge'); + + const result = await stub.dispatchPush( baseInput({ badge: null, idempotencyKey: 'k-no-badge' }) ); + expect(result.kind).toBe('delivered'); const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; expect(messages[0].badge).toBeUndefined(); + + const buckets = await runInDurableObject(stub, async (_inst, state) => { + const entries = await state.storage.list({ prefix: 'bucket:' }); + return Array.from(entries.keys()); + }); + expect(buckets).toEqual([]); }); it('does not write idempotency key on Expo failure', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); - const stub = getDO('conv-fail'); + const stub = getDO('user-fail'); const input = baseInput({ idempotencyKey: 'k-fail', badge: null }); const first = await stub.dispatchPush(input); expect(first.kind).toBe('failed'); @@ -129,46 +174,43 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(second.kind).not.toBe('duplicate'); }); - it('does not re-increment the badge when retrying after Expo failure', async () => { - const insertSpy = vi.fn().mockReturnValue({ - values: () => ({ onConflictDoUpdate: async () => undefined }), - }); - vi.spyOn(dbClient, 'getWorkerDb').mockReturnValue({ - select: () => ({ - from: (table: Parameters[0]) => ({ - where: async () => { - if (getTableName(table) === 'user_push_tokens') { - return [{ token: 'tok1' }]; - } - return [{ total: 1 }]; - }, - }), - }), - insert: insertSpy, - delete: () => ({ where: async () => undefined }), - } as unknown as ReturnType); + it('does not re-increment the bucket when retrying after Expo failure', async () => { + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); - const stub = getDO('conv-no-double'); + const stub = getDO('user-no-double'); const input = baseInput({ idempotencyKey: 'k-no-double' }); const first = await stub.dispatchPush(input); expect(first.kind).toBe('failed'); - expect(insertSpy).toHaveBeenCalledTimes(1); + + // After the failed attempt, the bucket has already been incremented once + // and the idem record is `pending`. + const afterFail = await runInDurableObject(stub, (_inst, state) => + state.storage.get('bucket:conv1') + ); + expect(afterFail).toBe(1); const second = await stub.dispatchPush(input); expect(second.kind).toBe('delivered'); - // Badge must not be incremented twice across the retry — the first - // attempt's `pending` marker gates the second insert out. - expect(insertSpy).toHaveBeenCalledTimes(1); + + // Bucket must not be incremented twice across the retry — the first + // attempt's `pending` marker gates the second increment out. + const afterRetry = await runInDurableObject(stub, (_inst, state) => + state.storage.get('bucket:conv1') + ); + expect(afterRetry).toBe(1); + + const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; + expect(messages[0].badge).toBe(1); }); it('schedules cleanup when writing the pending marker (failed send)', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 0 }); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('boom')); - const stub = getDO('conv-pending-alarm'); + const stub = getDO('user-pending-alarm'); const result = await stub.dispatchPush(baseInput({ idempotencyKey: 'k-pending-alarm' })); expect(result.kind).toBe('failed'); @@ -179,8 +221,8 @@ describe('NotificationChannelDO.dispatchPush', () => { }); it('reschedules cleanup for younger records when alarm fires', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); - const stub = getDO('conv-reschedule'); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); + const stub = getDO('user-reschedule'); const now = Date.now(); await runInDurableObject(stub, async (_inst, state) => { @@ -206,9 +248,9 @@ describe('NotificationChannelDO.dispatchPush', () => { }); it('does not reset the alarm on every successful send', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }], badgeTotal: 1 }); + installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); - const stub = getDO('conv-alarm'); + const stub = getDO('user-alarm'); await stub.dispatchPush(baseInput({ idempotencyKey: 'k-alarm-1' })); const firstAlarm = await runInDurableObject(stub, (_inst, state) => state.storage.getAlarm()); diff --git a/services/notifications/src/__tests__/routes-badges.test.ts b/services/notifications/src/__tests__/routes-badges.test.ts new file mode 100644 index 0000000000..5391f001a5 --- /dev/null +++ b/services/notifications/src/__tests__/routes-badges.test.ts @@ -0,0 +1,135 @@ +import { env, SELF, runInDurableObject } from 'cloudflare:test'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { signKiloToken } from '@kilocode/worker-utils'; + +import type * as do_module from '../dos/NotificationChannelDO'; + +type DOStub = DurableObjectStub; + +const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; + +function getDO(name: string): DOStub { + const id = env.NOTIFICATION_CHANNEL_DO.idFromName(name); + return env.NOTIFICATION_CHANNEL_DO.get(id); +} + +async function seedBuckets(stub: DOStub, buckets: Record) { + await runInDurableObject(stub, async (_inst, state) => { + for (const [bucket, count] of Object.entries(buckets)) { + await state.storage.put(`bucket:${bucket}`, count); + } + }); +} + +async function tokenFor(userId: string): Promise { + const { token } = await signKiloToken({ + userId, + pepper: null, + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + }); + return token; +} + +describe('badge HTTP routes', () => { + beforeEach(() => { + // The auth middleware reads NEXTAUTH_SECRET via getCachedSecret. The + // test JWT secret has to round-trip through the SecretsStore binding. + vi.spyOn(env.NEXTAUTH_SECRET, 'get').mockResolvedValue(TEST_JWT_SECRET); + }); + + describe('GET /v1/badges', () => { + it('returns 401 without a bearer token', async () => { + const res = await SELF.fetch('https://example.com/v1/badges'); + expect(res.status).toBe(401); + }); + + it('returns the user buckets for a valid JWT', async () => { + const userId = 'user-routes-list'; + await seedBuckets(getDO(userId), { + 'kiloclaw:sb1:c1': 2, + 'kiloclaw:sb1:c2': 1, + }); + + const token = await tokenFor(userId); + const res = await SELF.fetch('https://example.com/v1/badges', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + const body = await res.json<{ + buckets: { badgeBucket: string; badgeCount: number }[]; + }>(); + body.buckets.sort((a, b) => a.badgeBucket.localeCompare(b.badgeBucket)); + expect(body).toEqual({ + buckets: [ + { badgeBucket: 'kiloclaw:sb1:c1', badgeCount: 2 }, + { badgeBucket: 'kiloclaw:sb1:c2', badgeCount: 1 }, + ], + }); + }); + + it('isolates buckets per caller - JWT for userA never sees userB state', async () => { + const userA = 'user-routes-a'; + const userB = 'user-routes-b'; + await seedBuckets(getDO(userB), { 'kiloclaw:sb:c': 7 }); + + const tokenA = await tokenFor(userA); + const res = await SELF.fetch('https://example.com/v1/badges', { + headers: { authorization: `Bearer ${tokenA}` }, + }); + expect(res.status).toBe(200); + const body = await res.json<{ buckets: unknown[] }>(); + expect(body.buckets).toEqual([]); + }); + }); + + describe('POST /v1/badges/mark-read', () => { + it('returns 401 without a bearer token', async () => { + const res = await SELF.fetch('https://example.com/v1/badges/mark-read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ badgeBucket: 'conv1' }), + }); + expect(res.status).toBe(401); + }); + + it('returns 400 when badgeBucket is missing', async () => { + const userId = 'user-routes-mark-bad'; + const token = await tokenFor(userId); + const res = await SELF.fetch('https://example.com/v1/badges/mark-read', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'badgeBucket required' }); + }); + + it('clears the bucket and returns the new total', async () => { + const userId = 'user-routes-mark'; + await seedBuckets(getDO(userId), { conv1: 2, conv2: 5 }); + + const token = await tokenFor(userId); + const res = await SELF.fetch('https://example.com/v1/badges/mark-read', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ badgeBucket: 'conv1' }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ badgeCount: 5 }); + + // Confirm storage matches. + const remaining = await runInDurableObject(getDO(userId), async (_inst, state) => { + const entries = await state.storage.list({ prefix: 'bucket:' }); + return Array.from(entries.entries()); + }); + expect(remaining).toEqual([['bucket:conv2', 5]]); + }); + }); +}); diff --git a/services/notifications/src/__tests__/send-push-for-conversation.test.ts b/services/notifications/src/__tests__/send-push-for-conversation.test.ts index 2eb5365773..2296400b8a 100644 --- a/services/notifications/src/__tests__/send-push-for-conversation.test.ts +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -22,11 +22,13 @@ const baseInput = ( }); describe('NotificationsService.sendPushForConversation', () => { - it('excludes sender, dedupes, fans out to remaining recipients', async () => { + it('excludes sender, dedupes, and routes one DO per recipient userId', async () => { const stubSpy = vi.fn(async (_input: DispatchPushInput) => ({ kind: 'delivered' as const, tokenCount: 1, })); + // Spy on idFromName to confirm the DO is keyed by userId, not conversationId. + const idFromNameSpy = vi.spyOn(env.NOTIFICATION_CHANNEL_DO, 'idFromName'); vi.spyOn(env.NOTIFICATION_CHANNEL_DO, 'get').mockReturnValue({ dispatchPush: stubSpy, } as unknown as DurableObjectStub); @@ -34,6 +36,10 @@ describe('NotificationsService.sendPushForConversation', () => { const result = await env.SELF.sendPushForConversation(baseInput()); expect(stubSpy).toHaveBeenCalledTimes(2); // r1, r2 + const idArgs = idFromNameSpy.mock.calls.map(c => c[0]); + expect(idArgs).toEqual(['r1', 'r2']); + expect(idArgs).not.toContain('conv1'); + expect(result.perRecipient.map((r: PerRecipientResult) => r.userId).sort()).toEqual([ 'r1', 'r2', diff --git a/services/notifications/src/__tests__/setup.ts b/services/notifications/src/__tests__/setup.ts index d54904d430..b24a109803 100644 --- a/services/notifications/src/__tests__/setup.ts +++ b/services/notifications/src/__tests__/setup.ts @@ -6,7 +6,6 @@ vi.mock('@kilocode/db/client', () => ({ from: (table: { _: { name: string } }) => ({ where: () => { if (table._.name === 'user_push_tokens') return []; - if (table._.name === 'badge_counts') return [{ total: 0 }]; return []; }, }), diff --git a/services/notifications/src/auth.ts b/services/notifications/src/auth.ts new file mode 100644 index 0000000000..d02ea1de6d --- /dev/null +++ b/services/notifications/src/auth.ts @@ -0,0 +1,36 @@ +import { createMiddleware } from 'hono/factory'; +import { extractBearerToken, getCachedSecret, verifyKiloToken } from '@kilocode/worker-utils'; +import { logger } from './util/logger'; + +export type AuthContext = { + callerId: string; + callerKind: 'user'; +}; + +/** + * Public HTTP auth for the notifications worker — humans only. The bearer is + * a Kilo JWT verified with NEXTAUTH_SECRET. + * + * The worker also exposes RPC methods to other workers (e.g. kilo-chat). RPC + * callers don't go through this middleware; HTTP traffic is JWT-only. + */ +export const authMiddleware = createMiddleware<{ + Bindings: Env; + Variables: AuthContext; +}>(async (c, next) => { + const token = extractBearerToken(c.req.header('authorization')); + if (!token) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const jwtSecret = await getCachedSecret(c.env.NEXTAUTH_SECRET, 'NEXTAUTH_SECRET'); + const payload = await verifyKiloToken(token, jwtSecret); + c.set('callerId', payload.kiloUserId); + c.set('callerKind', 'user'); + logger.setTags({ callerId: payload.kiloUserId, callerKind: 'user' }); + return next(); + } catch { + return c.json({ error: 'Unauthorized' }, 401); + } +}); diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 195bdd82e9..5fa6916bb9 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,8 +1,8 @@ import { DurableObject } from 'cloudflare:workers'; import { getWorkerDb } from '@kilocode/db/client'; -import { badge_counts, user_push_tokens } from '@kilocode/db/schema'; +import { user_push_tokens } from '@kilocode/db/schema'; import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/notifications'; -import { eq, inArray, sql, sum } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import type { ExpoPushMessage, TicketTokenPair } from '../lib/expo-push'; import { sendPushNotifications } from '../lib/expo-push'; @@ -16,14 +16,15 @@ type ReceiptCheckMessage = { ticketTokenPairs: TicketTokenPair[] }; type IdemRecord = { stage: 'pending' | 'delivered'; ts: number }; const IDEM_PREFIX = 'idem:'; +const BUCKET_PREFIX = 'bucket:'; const IDEM_TTL_MS = 60 * 60 * 1000; // 1 hour export class NotificationChannelDO extends DurableObject { async dispatchPush(input: DispatchPushInput): Promise { // 1. Idempotency. DO is single-threaded — requests for a given - // conversation serialize on this instance. A `failed` outcome - // leaves the record at `pending` so upstream can retry the send - // without re-incrementing the badge. + // user serialize on this instance. A `failed` outcome leaves the + // record at `pending` so upstream can retry the send without + // re-incrementing the badge. const idemKey = `${IDEM_PREFIX}${input.idempotencyKey}`; const existing = await this.ctx.storage.get(idemKey); if (existing?.stage === 'delivered') return { kind: 'duplicate' }; @@ -60,23 +61,9 @@ export class NotificationChannelDO extends DurableObject { // Also schedule cleanup at this point — if Expo keeps failing and // no future push ever lands, `pending` would otherwise leak. await this.ensureCleanupAlarm(ts); - await db - .insert(badge_counts) - .values({ - user_id: input.userId, - badge_bucket: input.badge.badgeBucket, - badge_count: input.badge.delta, - }) - .onConflictDoUpdate({ - target: [badge_counts.user_id, badge_counts.badge_bucket], - set: { badge_count: sql`${badge_counts.badge_count} + ${input.badge.delta}` }, - }); + await this.incrementBucket(input.badge.badgeBucket, input.badge.delta); } - const [totals] = await db - .select({ total: sum(badge_counts.badge_count) }) - .from(badge_counts) - .where(eq(badge_counts.user_id, input.userId)); - badgeTotal = Number(totals?.total ?? 0); + badgeTotal = await this.getTotal(); } // 5. Send via Expo @@ -120,6 +107,30 @@ export class NotificationChannelDO extends DurableObject { return { kind: 'delivered', tokenCount: tokens.length }; } + /** + * Clear a bucket and return the user's new total. Called when a user + * marks a conversation as read. + */ + async markBucketRead(bucket: string): Promise { + await this.ctx.storage.delete(`${BUCKET_PREFIX}${bucket}`); + return this.getTotal(); + } + + /** + * Return all non-zero buckets for this user, used to hydrate clients on + * cold start. + */ + async listNonZeroBuckets(): Promise<{ badgeBucket: string; badgeCount: number }[]> { + const entries = await this.ctx.storage.list({ prefix: BUCKET_PREFIX }); + const out: { badgeBucket: string; badgeCount: number }[] = []; + for (const [key, count] of entries) { + if (count > 0) { + out.push({ badgeBucket: key.slice(BUCKET_PREFIX.length), badgeCount: count }); + } + } + return out; + } + override async alarm(): Promise { const now = Date.now(); const entries = await this.ctx.storage.list({ prefix: IDEM_PREFIX }); @@ -133,8 +144,8 @@ export class NotificationChannelDO extends DurableObject { } } if (expired.length > 0) await this.ctx.storage.delete(expired); - // Reschedule for the earliest remaining record so a quiet conversation - // still gets its leftover entries pruned exactly once their TTL elapses. + // Reschedule for the earliest remaining record so a quiet user still + // gets its leftover entries pruned exactly once their TTL elapses. if (earliestRemaining !== undefined) { await this.ctx.storage.setAlarm(earliestRemaining + IDEM_TTL_MS); } @@ -142,10 +153,26 @@ export class NotificationChannelDO extends DurableObject { // Schedule cleanup `IDEM_TTL_MS` from `refTs` only if no alarm is pending. // `setAlarm` replaces any existing alarm; calling it unconditionally would - // push cleanup forward indefinitely on a busy conversation. + // push cleanup forward indefinitely on a busy user. private async ensureCleanupAlarm(refTs: number): Promise { if ((await this.ctx.storage.getAlarm()) === null) { await this.ctx.storage.setAlarm(refTs + IDEM_TTL_MS); } } + + // Read-modify-write of a bucket counter. The DO is single-threaded, so + // this is race-free without explicit locking. + private async incrementBucket(bucket: string, delta: number): Promise { + const key = `${BUCKET_PREFIX}${bucket}`; + const current = (await this.ctx.storage.get(key)) ?? 0; + await this.ctx.storage.put(key, current + delta); + } + + // Sum of all bucket counters for this user. + private async getTotal(): Promise { + const entries = await this.ctx.storage.list({ prefix: BUCKET_PREFIX }); + let total = 0; + for (const value of entries.values()) total += value; + return total; + } } diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index bb4f673346..7f0c5352ca 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -1,5 +1,8 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import { Hono } from 'hono'; +import type { MiddlewareHandler } from 'hono'; +import { cors } from 'hono/cors'; +import { useWorkersLogger } from 'workers-tagged-logger'; import { presenceContextForConversation } from '@kilocode/event-service'; import { @@ -11,14 +14,52 @@ import { type SendPushForConversationOutput, } from '@kilocode/notifications'; +import { authMiddleware, type AuthContext } from './auth'; import { queue } from './queue-consumer'; export { NotificationChannelDO } from './dos/NotificationChannelDO'; -const app = new Hono<{ Bindings: Env }>(); +const ALLOWED_ORIGINS = ['https://kilo.ai', 'https://app.kilo.ai', 'http://localhost:3000']; + +const app = new Hono<{ Bindings: Env; Variables: AuthContext }>(); + +// ── Structured logging context ────────────────────────────────────────── +// Establishes AsyncLocalStorage context so all downstream logs (including +// tags set by the auth middleware) propagate through the request. +// Cast needed: workers-tagged-logger@1.0.0 was built against an older Hono. +app.use('*', useWorkersLogger('notifications') as unknown as MiddlewareHandler); + app.get('/', c => c.json({ ok: true })); -type ConversationDOStub = { +app.use( + '/v1/*', + cors({ + origin: origin => (ALLOWED_ORIGINS.includes(origin) ? origin : null), + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + }) +); +app.use('/v1/*', authMiddleware); + +app.get('/v1/badges', async c => { + const userId = c.get('callerId'); + const stub = c.env.NOTIFICATION_CHANNEL_DO.get(c.env.NOTIFICATION_CHANNEL_DO.idFromName(userId)); + const buckets = await stub.listNonZeroBuckets(); + return c.json({ buckets }); +}); + +app.post('/v1/badges/mark-read', async c => { + const userId = c.get('callerId'); + const body = await c.req.json<{ badgeBucket?: string }>().catch(() => null); + if (!body?.badgeBucket) { + return c.json({ error: 'badgeBucket required' }, 400); + } + const stub = c.env.NOTIFICATION_CHANNEL_DO.get(c.env.NOTIFICATION_CHANNEL_DO.idFromName(userId)); + const badgeCount = await stub.markBucketRead(body.badgeBucket); + return c.json({ badgeCount }); +}); + +type RecipientDOStub = { dispatchPush: (input: DispatchPushInput) => Promise; }; @@ -26,7 +67,7 @@ type ConversationDOStub = { export async function sendPushForConversationCore( input: SendPushForConversationInput, deps: { - getConversationDOStub: (conversationId: string) => ConversationDOStub; + getRecipientDOStub: (userId: string) => RecipientDOStub; } ): Promise { const recipients: string[] = []; @@ -40,7 +81,7 @@ export async function sendPushForConversationCore( const perRecipient: PerRecipientResult[] = []; for (const userId of recipients) { - const stub = deps.getConversationDOStub(input.conversationId); + const stub = deps.getRecipientDOStub(userId); const outcome = await stub.dispatchPush({ userId, presenceContext: presenceContextForConversation(input.sandboxId, input.conversationId), @@ -80,10 +121,10 @@ export class NotificationsService extends WorkerEntrypoint { input: SendPushForConversationInput ): Promise { return sendPushForConversationCore(input, { - getConversationDOStub: (conversationId: string) => + getRecipientDOStub: (userId: string) => this.env.NOTIFICATION_CHANNEL_DO.get( - this.env.NOTIFICATION_CHANNEL_DO.idFromName(conversationId) - ) as unknown as ConversationDOStub, + this.env.NOTIFICATION_CHANNEL_DO.idFromName(userId) + ) as unknown as RecipientDOStub, }); } } diff --git a/services/notifications/src/util/logger.ts b/services/notifications/src/util/logger.ts new file mode 100644 index 0000000000..5029b7a0e6 --- /dev/null +++ b/services/notifications/src/util/logger.ts @@ -0,0 +1,23 @@ +/** + * Structured logging powered by workers-tagged-logger. + * + * Uses AsyncLocalStorage so tags (callerId, etc.) propagate to all downstream + * functions without explicit parameter passing. + * + * Setup: + * - In the Hono worker: use `useWorkersLogger` middleware to establish context. + * - In DOs: wrap the entry point (alarm, RPC) with `withLogTags`. + * - Anywhere: call `logger.setTags({ callerId })` to tag all subsequent logs. + */ + +import { WorkersLogger, withLogTags } from 'workers-tagged-logger'; + +export type LogTags = { + source?: string; + callerId?: string; + callerKind?: 'user'; + badgeBucket?: string; +}; + +export const logger = new WorkersLogger(); +export { withLogTags }; diff --git a/services/notifications/worker-configuration.d.ts b/services/notifications/worker-configuration.d.ts index 0e8a5f8948..f9a0f89aa6 100644 --- a/services/notifications/worker-configuration.d.ts +++ b/services/notifications/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 35f3a1e5a589a3db24bda461e9af3ff0) +// Generated by Wrangler by running `wrangler types` (hash: 33c16dd3787d29555b5de05083b22531) // Runtime types generated with workerd@1.20260312.1 2026-02-01 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -10,6 +10,7 @@ declare namespace Cloudflare { HYPERDRIVE: Hyperdrive; RECEIPTS_QUEUE: Queue; EXPO_ACCESS_TOKEN: SecretsStoreSecret; + NEXTAUTH_SECRET: SecretsStoreSecret; NOTIFICATION_CHANNEL_DO: DurableObjectNamespace; EVENT_SERVICE: Fetcher /* event-service */; } diff --git a/services/notifications/wrangler.jsonc b/services/notifications/wrangler.jsonc index ab9c249bc5..212809828a 100644 --- a/services/notifications/wrangler.jsonc +++ b/services/notifications/wrangler.jsonc @@ -63,6 +63,11 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "EXPO_ACCESS_TOKEN", }, + { + "binding": "NEXTAUTH_SECRET", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "NEXTAUTH_SECRET_PROD", + }, ], "migrations": [ From 5c46ca54cf16f8306cfc45e74adac7e4e76f5cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:28:08 +0200 Subject: [PATCH 019/289] fix(notifications): share badge endpoint schemas --- .../kilo-chat/hooks/use-mark-read.ts | 22 ++++++++++------ .../mobile/src/lib/hooks/use-unread-counts.ts | 8 +++--- packages/notifications/src/rpc-schemas.ts | 26 +++++++++++++++++++ .../src/__tests__/routes-badges.test.ts | 15 +++++++++++ services/notifications/src/index.ts | 16 ++++++++---- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index be54547dbf..3e22698753 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -3,16 +3,21 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as Notifications from 'expo-notifications'; import { toast } from 'sonner-native'; -import { badgeBucketForConversation } from '@kilocode/notifications'; +import { + badgeBucketForConversation, + type BadgeCountRow, + type MarkBadgeReadInput, + type MarkBadgeReadResponse, + markBadgeReadResponseSchema, +} from '@kilocode/notifications'; import { NOTIFICATIONS_URL } from '@/lib/config'; import { useCurrentUserId } from './use-current-user-id'; import { useKiloChatTokenGetter } from './use-kilo-chat-token'; -type BadgeBucket = { badgeBucket: string; badgeCount: number }; type MarkReadContext = { - previousBadges?: BadgeBucket[]; + previousBadges?: BadgeCountRow[]; queryKey?: readonly ['badges', string]; }; @@ -22,20 +27,21 @@ export function useMarkRead() { const getToken = useKiloChatTokenGetter(); const mutation = useMutation({ - mutationFn: async (badgeBucket: string): Promise<{ badgeCount: number }> => { + mutationFn: async (badgeBucket: string): Promise => { const token = await getToken(); + const input = { badgeBucket } satisfies MarkBadgeReadInput; const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ badgeBucket }), + body: JSON.stringify(input), }); if (!response.ok) { throw new Error(`Failed to mark badge read: ${response.status}`); } - return (await response.json()) as { badgeCount: number }; + return markBadgeReadResponseSchema.parse(await response.json()); }, onMutate: async (badgeBucket): Promise => { if (userId === null) { @@ -44,9 +50,9 @@ export function useMarkRead() { const queryKey = ['badges', userId] as const; await queryClient.cancelQueries({ queryKey }); - const previousBadges = queryClient.getQueryData(queryKey); + const previousBadges = queryClient.getQueryData(queryKey); - queryClient.setQueryData(queryKey, badges => + queryClient.setQueryData(queryKey, badges => badges?.filter(row => row.badgeBucket !== badgeBucket) ); diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index e07f0ffd17..cb36ad373d 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,13 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { type BadgeCountRow, listBadgesResponseSchema } from '@kilocode/notifications'; + import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token'; import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { NOTIFICATIONS_URL } from '@/lib/config'; -type Bucket = { badgeBucket: string; badgeCount: number }; - /** * Fetches unread message counts for the current user from the notifications * worker and returns a Map keyed by instance badge bucket for O(1) lookup from @@ -23,7 +23,7 @@ export function useUnreadCounts() { const userId = useCurrentUserId(); const getToken = useKiloChatTokenGetter(); - const query = useQuery({ + const query = useQuery({ queryKey: ['badges', userId], enabled: userId !== null, staleTime: 30_000, @@ -35,7 +35,7 @@ export function useUnreadCounts() { if (!response.ok) { throw new Error(`Failed to fetch badges: ${response.status}`); } - const body = (await response.json()) as { buckets: Bucket[] }; + const body = listBadgesResponseSchema.parse(await response.json()); return body.buckets; }, }); diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index 1e606cde2b..c707ff1490 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -35,6 +35,32 @@ export const sendPushForConversationOutputSchema = z.object({ }); export type SendPushForConversationOutput = z.infer; +// ── badge HTTP routes ─────────────────────────────────────────────── + +export const badgeBucketSchema = z.string().min(1); +export type BadgeBucket = z.infer; + +export const badgeCountRowSchema = z.object({ + badgeBucket: badgeBucketSchema, + badgeCount: z.number().int().nonnegative(), +}); +export type BadgeCountRow = z.infer; + +export const listBadgesResponseSchema = z.object({ + buckets: z.array(badgeCountRowSchema), +}); +export type ListBadgesResponse = z.infer; + +export const markBadgeReadInputSchema = z.object({ + badgeBucket: badgeBucketSchema, +}); +export type MarkBadgeReadInput = z.infer; + +export const markBadgeReadResponseSchema = z.object({ + badgeCount: z.number().int().nonnegative(), +}); +export type MarkBadgeReadResponse = z.infer; + // ── dispatchPush (internal DO RPC) ────────────────────────────────── export const dispatchPushInputSchema = z.object({ diff --git a/services/notifications/src/__tests__/routes-badges.test.ts b/services/notifications/src/__tests__/routes-badges.test.ts index 5391f001a5..bcf274ee5d 100644 --- a/services/notifications/src/__tests__/routes-badges.test.ts +++ b/services/notifications/src/__tests__/routes-badges.test.ts @@ -108,6 +108,21 @@ describe('badge HTTP routes', () => { expect(await res.json()).toEqual({ error: 'badgeBucket required' }); }); + it('returns 400 when badgeBucket is not a string', async () => { + const userId = 'user-routes-mark-invalid'; + const token = await tokenFor(userId); + const res = await SELF.fetch('https://example.com/v1/badges/mark-read', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ badgeBucket: 123 }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'badgeBucket required' }); + }); + it('clears the bucket and returns the new total', async () => { const userId = 'user-routes-mark'; await seedBuckets(getDO(userId), { conv1: 2, conv2: 5 }); diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 8299d1cf46..0d9310bd2d 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -10,8 +10,11 @@ import { useWorkersLogger } from 'workers-tagged-logger'; import { presenceContextForConversation } from '@kilocode/event-service'; import { badgeBucketForConversation, + markBadgeReadInputSchema, type DispatchPushInput, type DispatchPushOutcome, + type ListBadgesResponse, + type MarkBadgeReadResponse, type PerRecipientResult, type SendPushForConversationInput, type SendPushForConversationOutput, @@ -60,18 +63,21 @@ app.get('/v1/badges', async c => { const userId = c.get('callerId'); const stub = c.env.NOTIFICATION_CHANNEL_DO.get(c.env.NOTIFICATION_CHANNEL_DO.idFromName(userId)); const buckets = await stub.listNonZeroBuckets(); - return c.json({ buckets }); + const response = { buckets } satisfies ListBadgesResponse; + return c.json(response); }); app.post('/v1/badges/mark-read', async c => { const userId = c.get('callerId'); - const body = await c.req.json<{ badgeBucket?: string }>().catch(() => null); - if (!body?.badgeBucket) { + const body: unknown = await c.req.json().catch(() => null); + const parsedBody = markBadgeReadInputSchema.safeParse(body); + if (!parsedBody.success) { return c.json({ error: 'badgeBucket required' }, 400); } const stub = c.env.NOTIFICATION_CHANNEL_DO.get(c.env.NOTIFICATION_CHANNEL_DO.idFromName(userId)); - const badgeCount = await stub.markBucketRead(body.badgeBucket); - return c.json({ badgeCount }); + const badgeCount = await stub.markBucketRead(parsedBody.data.badgeBucket); + const response = { badgeCount } satisfies MarkBadgeReadResponse; + return c.json(response); }); type RecipientDOStub = { From cc5a0a72b6a025fc966118fc6dec71c8b33d25a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:28:41 +0200 Subject: [PATCH 020/289] fix(notifications): share instance badge bucket helper --- apps/mobile/src/components/home/home-screen.tsx | 3 ++- apps/mobile/src/lib/badge-buckets.ts | 2 -- apps/mobile/src/lib/hooks/use-unread-counts.ts | 7 +++++-- packages/notifications/src/badge-buckets.ts | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) delete mode 100644 apps/mobile/src/lib/badge-buckets.ts diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index cbf474425c..c5a6fff84e 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'; import { AppState, RefreshControl, ScrollView, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { badgeBucketForInstance } from '@kilocode/notifications'; + import { AgentSessionsSection } from '@/components/home/agent-sessions-section'; import { AgentsPromoCard } from '@/components/home/agents-promo-card'; import { buildTimedGreeting, Greeting } from '@/components/home/greeting'; @@ -15,7 +17,6 @@ import { isTransitionalStatus } from '@/components/kiloclaw/status-badge'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; -import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { type ClawInstance, useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useUnreadCounts } from '@/lib/hooks/use-unread-counts'; diff --git a/apps/mobile/src/lib/badge-buckets.ts b/apps/mobile/src/lib/badge-buckets.ts deleted file mode 100644 index 017be2aa0d..0000000000 --- a/apps/mobile/src/lib/badge-buckets.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const badgeBucketForInstance = (sandboxId: string): `kiloclaw:${string}` => - `kiloclaw:${sandboxId}`; diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index cb36ad373d..9dbfe3668b 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,11 +1,14 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; -import { type BadgeCountRow, listBadgesResponseSchema } from '@kilocode/notifications'; +import { + badgeBucketForInstance, + type BadgeCountRow, + listBadgesResponseSchema, +} from '@kilocode/notifications'; import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token'; -import { badgeBucketForInstance } from '@/lib/badge-buckets'; import { NOTIFICATIONS_URL } from '@/lib/config'; /** diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts index 90259ed1e2..a1717c5618 100644 --- a/packages/notifications/src/badge-buckets.ts +++ b/packages/notifications/src/badge-buckets.ts @@ -7,3 +7,6 @@ export const badgeBucketForConversation = (sandboxId: string, conversationId: string) => `kiloclaw:${sandboxId}:${conversationId}` as const; + +export const badgeBucketForInstance = (sandboxId: string): `kiloclaw:${string}` => + `kiloclaw:${sandboxId}`; From f2423a14c7bea64b06facd20bedbc69705bbe2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:35:33 +0200 Subject: [PATCH 021/289] fix(notifications): share lifecycle notification rpc types --- packages/notifications/src/rpc-schemas.ts | 27 ++++++++++++ pnpm-lock.yaml | 3 ++ services/kiloclaw/package.json | 1 + .../kiloclaw/src/notifications-binding.ts | 29 +++++------- services/notifications/src/index.ts | 10 ++--- .../src/lib/instance-lifecycle-push.ts | 44 +++++-------------- .../src/lib/notifications-service.ts | 16 +++---- 7 files changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index c707ff1490..0e1b53b7bd 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -61,6 +61,33 @@ export const markBadgeReadResponseSchema = z.object({ }); export type MarkBadgeReadResponse = z.infer; +// ── sendInstanceLifecycleNotification ─────────────────────────────── + +export const instanceLifecycleEventSchema = z.enum(['ready', 'start_failed']); +export type InstanceLifecycleEvent = z.infer; + +export const sendInstanceLifecycleNotificationInputSchema = z.object({ + userId: z.string().min(1), + instanceId: z.string().min(1), + sandboxId: z.string(), + event: instanceLifecycleEventSchema, + instanceName: z.string().nullable(), + errorMessage: z.string().optional(), +}); +export type SendInstanceLifecycleNotificationParams = z.infer< + typeof sendInstanceLifecycleNotificationInputSchema +>; + +export const sendInstanceLifecycleNotificationOutputSchema = z.object({ + tokenCount: z.number().int().nonnegative(), + sent: z.number().int().nonnegative(), + staleTokens: z.number().int().nonnegative(), + receiptCount: z.number().int().nonnegative(), +}); +export type SendInstanceLifecycleNotificationResult = z.infer< + typeof sendInstanceLifecycleNotificationOutputSchema +>; + // ── dispatchPush (internal DO RPC) ────────────────────────────────── export const dispatchPushInputSchema = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f2c158087..8faa85d3ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1910,6 +1910,9 @@ importers: '@kilocode/kiloclaw-secret-catalog': specifier: workspace:* version: link:../../packages/kiloclaw-secret-catalog + '@kilocode/notifications': + specifier: workspace:* + version: link:../../packages/notifications '@kilocode/worker-utils': specifier: workspace:* version: link:../../packages/worker-utils diff --git a/services/kiloclaw/package.json b/services/kiloclaw/package.json index 443ba5fe5c..6849894031 100644 --- a/services/kiloclaw/package.json +++ b/services/kiloclaw/package.json @@ -24,6 +24,7 @@ "@kilocode/encryption": "workspace:*", "@kilocode/kilo-chat": "workspace:*", "@kilocode/kiloclaw-secret-catalog": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@northflank/js-client": "^0.9.3", "drizzle-orm": "catalog:", diff --git a/services/kiloclaw/src/notifications-binding.ts b/services/kiloclaw/src/notifications-binding.ts index 7e5c4213dd..5b066fdc5f 100644 --- a/services/kiloclaw/src/notifications-binding.ts +++ b/services/kiloclaw/src/notifications-binding.ts @@ -3,28 +3,19 @@ * * `wrangler types` only sees `Fetcher` for service bindings; the actual RPC * shape comes from the notifications worker's WorkerEntrypoint and is declared - * here so the generated file can be freely regenerated. - * - * Keep in sync with: services/notifications/src/lib/notifications-service.ts (NotificationsService). + * here from shared package types so the generated file can be freely regenerated. */ -export type InstanceLifecycleEvent = 'ready' | 'start_failed'; - -export type SendInstanceLifecycleNotificationParams = { - userId: string; - instanceId: string; - sandboxId: string; - event: InstanceLifecycleEvent; - instanceName: string | null; - errorMessage?: string; -}; +import type { + SendInstanceLifecycleNotificationParams, + SendInstanceLifecycleNotificationResult, +} from '@kilocode/notifications'; -export type SendInstanceLifecycleNotificationResult = { - tokenCount: number; - sent: number; - staleTokens: number; - receiptCount: number; -}; +export type { + InstanceLifecycleEvent, + SendInstanceLifecycleNotificationParams, + SendInstanceLifecycleNotificationResult, +} from '@kilocode/notifications'; export type NotificationsBinding = Fetcher & { sendInstanceLifecycleNotification( diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 0d9310bd2d..9d93166918 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -13,6 +13,8 @@ import { markBadgeReadInputSchema, type DispatchPushInput, type DispatchPushOutcome, + type SendInstanceLifecycleNotificationParams, + type SendInstanceLifecycleNotificationResult, type ListBadgesResponse, type MarkBadgeReadResponse, type PerRecipientResult, @@ -23,11 +25,7 @@ import { import { authMiddleware, type AuthContext } from './auth'; import type { TicketTokenPair } from './lib/expo-push'; import { sendPushNotifications } from './lib/expo-push'; -import { - dispatchInstanceLifecyclePush, - type SendInstanceLifecycleNotificationParams, - type SendInstanceLifecycleNotificationResult, -} from './lib/instance-lifecycle-push'; +import { dispatchInstanceLifecyclePush } from './lib/instance-lifecycle-push'; import { queue } from './queue-consumer'; export { NotificationChannelDO } from './dos/NotificationChannelDO'; @@ -35,7 +33,7 @@ export type { InstanceLifecycleEvent, SendInstanceLifecycleNotificationParams, SendInstanceLifecycleNotificationResult, -} from './lib/instance-lifecycle-push'; +} from '@kilocode/notifications'; const ALLOWED_ORIGINS = ['https://kilo.ai', 'https://app.kilo.ai', 'http://localhost:3000']; diff --git a/services/notifications/src/lib/instance-lifecycle-push.ts b/services/notifications/src/lib/instance-lifecycle-push.ts index 4a2f0e91b2..92b8356997 100644 --- a/services/notifications/src/lib/instance-lifecycle-push.ts +++ b/services/notifications/src/lib/instance-lifecycle-push.ts @@ -4,39 +4,20 @@ * pulling in the Hyperdrive/pg client chain. */ -import { z } from 'zod'; +import { + sendInstanceLifecycleNotificationInputSchema, + type InstanceLifecycleEvent, + type SendInstanceLifecycleNotificationParams, + type SendInstanceLifecycleNotificationResult, +} from '@kilocode/notifications'; import type { ExpoPushMessage, SendResult, TicketTokenPair } from './expo-push'; -export type InstanceLifecycleEvent = 'ready' | 'start_failed'; - -export type SendInstanceLifecycleNotificationParams = { - userId: string; - /** Chat route id surfaced on the device. Currently this is the instance sandboxId. */ - instanceId: string; - /** Included for worker-side logs only. */ - sandboxId: string; - event: InstanceLifecycleEvent; - instanceName: string | null; - /** Failure body only. Caller is expected to keep this short (~100 chars). */ - errorMessage?: string; -}; - -export type SendInstanceLifecycleNotificationResult = { - tokenCount: number; - sent: number; - staleTokens: number; - receiptCount: number; -}; - -export const ParamsSchema = z.object({ - userId: z.string().min(1), - instanceId: z.string().min(1), - sandboxId: z.string(), - event: z.enum(['ready', 'start_failed']), - instanceName: z.string().nullable(), - errorMessage: z.string().optional(), -}); +export type { + InstanceLifecycleEvent, + SendInstanceLifecycleNotificationParams, + SendInstanceLifecycleNotificationResult, +} from '@kilocode/notifications'; const BODY_MAX_LENGTH = 100; @@ -71,7 +52,6 @@ export function buildInstanceLifecycleMessages( to: token, title, body, - // Keep in sync with NotificationData in apps/mobile/src/lib/notifications.ts data: { type: 'instance-lifecycle', event: params.event, @@ -97,7 +77,7 @@ export async function dispatchInstanceLifecyclePush( params: SendInstanceLifecycleNotificationParams, deps: LifecycleDispatchDeps ): Promise { - const parsed = ParamsSchema.parse(params); + const parsed = sendInstanceLifecycleNotificationInputSchema.parse(params); const tokens = await deps.getTokens(parsed.userId); if (tokens.length === 0) { diff --git a/services/notifications/src/lib/notifications-service.ts b/services/notifications/src/lib/notifications-service.ts index 8178d5529f..a69bf44e02 100644 --- a/services/notifications/src/lib/notifications-service.ts +++ b/services/notifications/src/lib/notifications-service.ts @@ -3,19 +3,20 @@ import { getWorkerDb } from '@kilocode/db/client'; import { user_push_tokens } from '@kilocode/db/schema'; import { eq, inArray } from 'drizzle-orm'; +import type { + SendInstanceLifecycleNotificationParams, + SendInstanceLifecycleNotificationResult, +} from '@kilocode/notifications'; + import type { TicketTokenPair } from './expo-push'; import { sendPushNotifications } from './expo-push'; -import { - dispatchInstanceLifecyclePush, - type SendInstanceLifecycleNotificationParams, - type SendInstanceLifecycleNotificationResult, -} from './instance-lifecycle-push'; +import { dispatchInstanceLifecyclePush } from './instance-lifecycle-push'; export type { InstanceLifecycleEvent, SendInstanceLifecycleNotificationParams, SendInstanceLifecycleNotificationResult, -} from './instance-lifecycle-push'; +} from '@kilocode/notifications'; type ReceiptCheckMessage = { ticketTokenPairs: TicketTokenPair[]; @@ -28,8 +29,7 @@ type ReceiptCheckMessage = { * explicitly bound to `notifications` with `entrypoint: "NotificationsService"` * can reach these methods. No shared secret is needed. * - * Keep `data.type` values in sync with `NotificationData` in - * `apps/mobile/src/lib/notifications.ts`. + * Push data is parsed by the shared `pushDataSchema` in `@kilocode/notifications`. */ export class NotificationsService extends WorkerEntrypoint { async sendInstanceLifecycleNotification( From 80cead6b1872efa36789a3d0a94febeeb522da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:36:33 +0200 Subject: [PATCH 022/289] fix(kilo-chat): share web token response schema --- .../web/src/app/(app)/claw/kilo-chat/token.ts | 4 +-- apps/web/src/app/api/kilo-chat/token/route.ts | 6 +++-- apps/web/src/lib/kilo-chat/token-schema.ts | 8 ++++++ apps/web/src/routers/kilo-chat-router.ts | 25 ++++++++++--------- 4 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/lib/kilo-chat/token-schema.ts diff --git a/apps/web/src/app/(app)/claw/kilo-chat/token.ts b/apps/web/src/app/(app)/claw/kilo-chat/token.ts index 4d76760091..5a92272db8 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/token.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/token.ts @@ -9,7 +9,7 @@ * Concurrent callers share the same inflight request. */ -import { z } from 'zod'; +import { kiloChatTokenResponseSchema } from '@/lib/kilo-chat/token-schema'; let cachedToken: string | null = null; let tokenExpiresAt: number = 0; @@ -24,7 +24,7 @@ async function fetchToken(): Promise { throw new Error(`Failed to fetch kilo-chat token: ${res.status} ${body}`); } const data: unknown = await res.json(); - const parsed = z.object({ token: z.string(), expiresAt: z.string() }).parse(data); + const parsed = kiloChatTokenResponseSchema.parse(data); cachedToken = parsed.token; tokenExpiresAt = new Date(parsed.expiresAt).getTime(); return parsed.token; diff --git a/apps/web/src/app/api/kilo-chat/token/route.ts b/apps/web/src/app/api/kilo-chat/token/route.ts index ace67d0e7d..27ba3f6ce0 100644 --- a/apps/web/src/app/api/kilo-chat/token/route.ts +++ b/apps/web/src/app/api/kilo-chat/token/route.ts @@ -1,7 +1,8 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { getUserFromAuth } from '@/lib/user.server'; +import { kiloChatTokenResponseSchema } from '@/lib/kilo-chat/token-schema'; import { generateApiToken } from '@/lib/tokens'; +import { getUserFromAuth } from '@/lib/user.server'; const ONE_HOUR_SECONDS = 60 * 60; @@ -30,6 +31,7 @@ export async function POST() { { expiresIn: ONE_HOUR_SECONDS } ); const expiresAt = new Date(Date.now() + ONE_HOUR_SECONDS * 1000).toISOString(); + const response = kiloChatTokenResponseSchema.parse({ token, expiresAt }); - return NextResponse.json({ token, expiresAt }); + return NextResponse.json(response); } diff --git a/apps/web/src/lib/kilo-chat/token-schema.ts b/apps/web/src/lib/kilo-chat/token-schema.ts new file mode 100644 index 0000000000..013846e21c --- /dev/null +++ b/apps/web/src/lib/kilo-chat/token-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const kiloChatTokenResponseSchema = z.object({ + token: z.string(), + expiresAt: z.iso.datetime(), +}); + +export type KiloChatTokenResponse = z.infer; diff --git a/apps/web/src/routers/kilo-chat-router.ts b/apps/web/src/routers/kilo-chat-router.ts index 4b9b4e4cd5..170c43ffd7 100644 --- a/apps/web/src/routers/kilo-chat-router.ts +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -1,20 +1,21 @@ import 'server-only'; -import * as z from 'zod'; +import { + kiloChatTokenResponseSchema, + type KiloChatTokenResponse, +} from '@/lib/kilo-chat/token-schema'; import { generateApiToken } from '@/lib/tokens'; import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; const KILO_CHAT_TOKEN_TTL_S = 60 * 60; export const kiloChatRouter = createTRPCRouter({ - getToken: baseProcedure - .output(z.object({ token: z.string(), expiresAt: z.iso.datetime() })) - .query(({ ctx }) => { - const token = generateApiToken( - ctx.user, - { tokenSource: 'kilo-chat' }, - { expiresIn: KILO_CHAT_TOKEN_TTL_S } - ); - const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_S * 1000).toISOString(); - return { token, expiresAt }; - }), + getToken: baseProcedure.output(kiloChatTokenResponseSchema).query(({ ctx }) => { + const token = generateApiToken( + ctx.user, + { tokenSource: 'kilo-chat' }, + { expiresIn: KILO_CHAT_TOKEN_TTL_S } + ); + const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_S * 1000).toISOString(); + return { token, expiresAt } satisfies KiloChatTokenResponse; + }), }); From 80e4209f32770b2aeeab4b0f5079b89ae257d67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:38:05 +0200 Subject: [PATCH 023/289] fix(kilo-chat): validate conversation cursor with zod --- packages/kilo-chat/src/schemas.ts | 5 +++ packages/kilo-chat/src/utils.ts | 19 +++------ packages/kilo-chat/test/utils.test.ts | 25 ++++++++++++ .../src/__tests__/membership-do.test.ts | 40 ++++++++++++++----- 4 files changed, 65 insertions(+), 24 deletions(-) create mode 100644 packages/kilo-chat/test/utils.test.ts diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 925fa5ecdc..222bc35d05 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -132,6 +132,11 @@ export const conversationListItemSchema = z.object({ joinedAt: z.number(), }); +export const conversationCursorSchema = z.object({ + t: z.number().int().nonnegative(), + c: ulidSchema, +}); + export const conversationDetailSchema = z.object({ id: z.string(), title: z.string().nullable(), diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts index 56f794469d..2210353c9b 100644 --- a/packages/kilo-chat/src/utils.ts +++ b/packages/kilo-chat/src/utils.ts @@ -1,4 +1,7 @@ import { decodeTime } from 'ulid'; +import type { z } from 'zod'; + +import { conversationCursorSchema } from './schemas'; /** Extract the millisecond timestamp encoded in a ULID. */ export function ulidToTimestamp(ulid: string): number { @@ -14,7 +17,7 @@ export function ulidToTimestamp(ulid: string): number { * `coalesce(last_activity_at, joined_at)`. `c` is the conversation_id * (ULID) tie-breaker. */ -export type ConversationCursor = { t: number; c: string }; +export type ConversationCursor = z.infer; function base64urlEncode(bytes: Uint8Array): string { let binary = ''; @@ -40,18 +43,8 @@ export function decodeConversationCursor(encoded: string): ConversationCursor | try { const json = new TextDecoder().decode(base64urlDecode(encoded)); const parsed: unknown = JSON.parse(json); - if ( - parsed !== null && - typeof parsed === 'object' && - 't' in parsed && - 'c' in parsed && - typeof (parsed as { t: unknown }).t === 'number' && - typeof (parsed as { c: unknown }).c === 'string' - ) { - const { t, c } = parsed as { t: number; c: string }; - return { t, c }; - } - return null; + const cursor = conversationCursorSchema.safeParse(parsed); + return cursor.success ? cursor.data : null; } catch { return null; } diff --git a/packages/kilo-chat/test/utils.test.ts b/packages/kilo-chat/test/utils.test.ts new file mode 100644 index 0000000000..e39dc3cac2 --- /dev/null +++ b/packages/kilo-chat/test/utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeConversationCursor, encodeConversationCursor } from '../src/utils'; + +const VALID_ULID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + +describe('conversation cursor helpers', () => { + it('round-trips a valid conversation cursor', () => { + const cursor = { t: 1_700_000_000_000, c: VALID_ULID }; + + expect(decodeConversationCursor(encodeConversationCursor(cursor))).toEqual(cursor); + }); + + it('rejects cursors with negative timestamps', () => { + const encoded = encodeConversationCursor({ t: -1, c: VALID_ULID }); + + expect(decodeConversationCursor(encoded)).toBeNull(); + }); + + it('rejects cursors with non-ULID tie-breakers', () => { + const encoded = encodeConversationCursor({ t: 1_700_000_000_000, c: 'not-a-ulid' }); + + expect(decodeConversationCursor(encoded)).toBeNull(); + }); +}); diff --git a/services/kilo-chat/src/__tests__/membership-do.test.ts b/services/kilo-chat/src/__tests__/membership-do.test.ts index 0e395e14ad..b18304b683 100644 --- a/services/kilo-chat/src/__tests__/membership-do.test.ts +++ b/services/kilo-chat/src/__tests__/membership-do.test.ts @@ -234,36 +234,54 @@ describe('MembershipDO', () => { describe('cursor pagination', () => { it('returns nextCursor when more rows are available and resumes with it', async () => { const stub = getStub('user-cursor-1'); - for (let i = 0; i < 5; i++) { + const conversationIds = [ + '01ARZ3NDEKTSV4RRFFQ69G5FA0', + '01ARZ3NDEKTSV4RRFFQ69G5FA1', + '01ARZ3NDEKTSV4RRFFQ69G5FA2', + '01ARZ3NDEKTSV4RRFFQ69G5FA3', + '01ARZ3NDEKTSV4RRFFQ69G5FA4', + ]; + for (let i = 0; i < conversationIds.length; i++) { await stub.addConversation({ - conversationId: `conv-${i}`, + conversationId: conversationIds[i], title: `Chat ${i}`, sandboxId: 'sandbox-1', joinedAt: 1000 + i, }); - await stub.updateLastActivity(`conv-${i}`, 10_000 + i); + await stub.updateLastActivity(conversationIds[i], 10_000 + i); } const page1 = await stub.listConversations({ limit: 2 }); expect(page1.hasMore).toBe(true); expect(page1.nextCursor).toBeTruthy(); - expect(page1.conversations.map(c => c.conversationId)).toEqual(['conv-4', 'conv-3']); + expect(page1.conversations.map(c => c.conversationId)).toEqual([ + conversationIds[4], + conversationIds[3], + ]); const page2 = await stub.listConversations({ limit: 2, cursor: decode(page1.nextCursor!) }); expect(page2.hasMore).toBe(true); - expect(page2.conversations.map(c => c.conversationId)).toEqual(['conv-2', 'conv-1']); + expect(page2.conversations.map(c => c.conversationId)).toEqual([ + conversationIds[2], + conversationIds[1], + ]); const page3 = await stub.listConversations({ limit: 2, cursor: decode(page2.nextCursor!) }); expect(page3.hasMore).toBe(false); expect(page3.nextCursor).toBeNull(); - expect(page3.conversations.map(c => c.conversationId)).toEqual(['conv-0']); + expect(page3.conversations.map(c => c.conversationId)).toEqual([conversationIds[0]]); }); it('paginates consistently when last_activity_at is null (falls back to joined_at)', async () => { const stub = getStub('user-cursor-2'); - for (let i = 0; i < 3; i++) { + const conversationIds = [ + '01BRZ3NDEKTSV4RRFFQ69G5FA0', + '01BRZ3NDEKTSV4RRFFQ69G5FA1', + '01BRZ3NDEKTSV4RRFFQ69G5FA2', + ]; + for (let i = 0; i < conversationIds.length; i++) { await stub.addConversation({ - conversationId: `conv-null-${i}`, + conversationId: conversationIds[i], title: null, sandboxId: 'sandbox-1', joinedAt: 1000 + i, @@ -271,14 +289,14 @@ describe('MembershipDO', () => { } const page1 = await stub.listConversations({ limit: 1 }); - expect(page1.conversations[0].conversationId).toBe('conv-null-2'); + expect(page1.conversations[0].conversationId).toBe(conversationIds[2]); expect(page1.hasMore).toBe(true); const page2 = await stub.listConversations({ limit: 1, cursor: decode(page1.nextCursor!) }); - expect(page2.conversations[0].conversationId).toBe('conv-null-1'); + expect(page2.conversations[0].conversationId).toBe(conversationIds[1]); const page3 = await stub.listConversations({ limit: 1, cursor: decode(page2.nextCursor!) }); - expect(page3.conversations[0].conversationId).toBe('conv-null-0'); + expect(page3.conversations[0].conversationId).toBe(conversationIds[0]); expect(page3.hasMore).toBe(false); }); }); From f3cfa2bd73155ff24c6670609febddd4af69ea3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:38:32 +0200 Subject: [PATCH 024/289] fix(kilo-chat): type event-service rpc payloads --- services/kilo-chat/src/bindings.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/kilo-chat/src/bindings.d.ts b/services/kilo-chat/src/bindings.d.ts index d6580663b6..3d033ade84 100644 --- a/services/kilo-chat/src/bindings.d.ts +++ b/services/kilo-chat/src/bindings.d.ts @@ -1,5 +1,5 @@ import type { z } from 'zod'; -import type { chatWebhookRpcSchema, KiloChatEventName } from '@kilocode/kilo-chat'; +import type { chatWebhookRpcSchema, KiloChatEventName, KiloChatEventOf } from '@kilocode/kilo-chat'; import type { SendPushForConversationInput, SendPushForConversationOutput, @@ -20,11 +20,11 @@ declare global { deliverChatWebhook(payload: z.infer): Promise; }; EVENT_SERVICE: Fetcher & { - pushEvent( + pushEvent( userId: string, context: string, - event: KiloChatEventName, - payload: unknown + event: N, + payload: KiloChatEventOf ): Promise; }; NOTIFICATIONS: Fetcher & { From bcc1686aac6532a44076e432be78a576cd4b067a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 16:59:34 +0200 Subject: [PATCH 025/289] fix(kilo-chat): address cutover correctness issues --- .../kilo-chat/conversation-screen.tsx | 11 ++- .../kilo-chat/hooks/mark-read-actions.ts | 18 +++++ .../use-conversation-event-subscription.ts | 36 +++++++++ .../kilo-chat/hooks/use-conversations.ts | 18 ++++- .../kilo-chat/hooks/use-mark-read.test.ts | 25 ++++++ .../kilo-chat/hooks/use-mark-read.ts | 57 +++++++++---- .../kilo-chat/hooks/use-messages.ts | 22 ++++- .../kilo-chat/live-message-cache.test.ts | 39 +++++++++ .../kilo-chat/message-list-order.test.ts | 26 ++++++ .../kilo-chat/message-list-order.ts | 5 ++ .../src/components/kilo-chat/message-list.tsx | 9 ++- apps/mobile/vitest.config.ts | 2 +- .../src/app/(app)/claw/components/ChatTab.tsx | 8 +- .../(app)/claw/components/ClawChatPage.tsx | 5 +- .../claw/components/chat-redirect.test.ts | 9 +++ .../(app)/claw/components/chat-redirect.ts | 3 + .../kilo-chat/components/KiloChatLayout.tsx | 5 +- .../conversation-read-events.test.ts | 9 +++ .../components/conversation-read-events.ts | 3 + .../kilo-chat-hooks/src/use-conversations.ts | 7 +- packages/kilo-chat-hooks/src/use-messages.ts | 80 ++++++++++++------- 21 files changed, 332 insertions(+), 65 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/live-message-cache.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-list-order.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-list-order.ts create mode 100644 apps/web/src/app/(app)/claw/components/chat-redirect.test.ts create mode 100644 apps/web/src/app/(app)/claw/components/chat-redirect.ts create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.test.ts create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 000c4a76a7..c4080009de 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -2,15 +2,17 @@ import * as Crypto from 'expo-crypto'; import { useCallback } from 'react'; import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; +import { toast } from 'sonner-native'; import { ConversationHeader } from './conversation-header'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; import { TypingIndicator } from './typing-indicator'; import { useConversationPresence } from './hooks/use-conversation-presence'; +import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; import { useMarkRead } from './hooks/use-mark-read'; -import { useMessages, useSendMessage } from './hooks/use-messages'; +import { useMessageCacheUpdater, useMessages, useSendMessage } from './hooks/use-messages'; import { useCurrentUserId } from './hooks/use-current-user-id'; import { setActiveChatLocation } from '@/lib/notifications'; @@ -42,8 +44,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl ); useConversationPresence(sandboxId, conversationId); + useConversationEventSubscription(sandboxId, conversationId); + const handleActionFailed = useCallback(() => { + toast.error("Couldn't reach the bot — please try again"); + }, []); + useMessageCacheUpdater(client, sandboxId, conversationId, undefined, handleActionFailed); - const markRead = useMarkRead(); + const markRead = useMarkRead(client); useFocusEffect( useCallback(() => { markRead(sandboxId, conversationId); diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts new file mode 100644 index 0000000000..a04ca68d6a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts @@ -0,0 +1,18 @@ +import { type MarkBadgeReadResponse } from '@kilocode/notifications'; + +type MarkConversationAndBadgeReadInput = { + conversationId: string; + badgeBucket: string; + markConversationRead: (conversationId: string) => Promise; + markBadgeRead: (badgeBucket: string) => Promise; +}; + +export async function markConversationAndBadgeRead({ + conversationId, + badgeBucket, + markConversationRead, + markBadgeRead, +}: MarkConversationAndBadgeReadInput): Promise { + await markConversationRead(conversationId); + return markBadgeRead(badgeBucket); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts new file mode 100644 index 0000000000..65a8e2248d --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-event-subscription.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { kiloclawConversationContext } from '@kilocode/event-service'; +import { messagesKey } from '@kilocode/kilo-chat-hooks'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +export function useConversationEventSubscription( + sandboxId: string | undefined, + conversationId: string | undefined +) { + const eventService = useEventServiceClient(); + const queryClient = useQueryClient(); + const context = + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null; + + useEffect(() => { + if (!context) { + return undefined; + } + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context]); + + useEffect(() => { + if (!conversationId) { + return undefined; + } + return eventService.onReconnect(() => { + void queryClient.invalidateQueries({ queryKey: messagesKey(conversationId) }); + }); + }, [eventService, queryClient, conversationId]); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts index 253bf6a85a..b781ea82b7 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts @@ -1,5 +1,17 @@ -export { - useConversations, +import { formatKiloChatError, type KiloChatClient } from '@kilocode/kilo-chat'; +import { useConversationDetail, - useCreateConversation, + useConversations, + useCreateConversation as useSharedCreateConversation, } from '@kilocode/kilo-chat-hooks'; +import { toast } from 'sonner-native'; + +export { useConversations, useConversationDetail }; + +export function useCreateConversation(client: KiloChatClient) { + return useSharedCreateConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to create conversation')); + }, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts new file mode 100644 index 0000000000..c7f1b6d696 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { markConversationAndBadgeRead } from './mark-read-actions'; + +describe('markConversationAndBadgeRead', () => { + it('marks the kilo-chat conversation and notification badge read', async () => { + const calls: string[] = []; + + await markConversationAndBadgeRead({ + conversationId: 'conversation-1', + badgeBucket: 'bucket-1', + markConversationRead: async conversationId => { + calls.push(`conversation:${conversationId}`); + await Promise.resolve(); + }, + markBadgeRead: async badgeBucket => { + calls.push(`badge:${badgeBucket}`); + await Promise.resolve(); + return { badgeCount: 3 }; + }, + }); + + expect(calls).toEqual(['conversation:conversation-1', 'badge:bucket-1']); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index 3e22698753..5003486bca 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -10,40 +10,61 @@ import { type MarkBadgeReadResponse, markBadgeReadResponseSchema, } from '@kilocode/notifications'; +import { type KiloChatClient } from '@kilocode/kilo-chat'; +import { useMarkConversationRead } from '@kilocode/kilo-chat-hooks'; import { NOTIFICATIONS_URL } from '@/lib/config'; import { useCurrentUserId } from './use-current-user-id'; import { useKiloChatTokenGetter } from './use-kilo-chat-token'; +import { markConversationAndBadgeRead } from './mark-read-actions'; type MarkReadContext = { previousBadges?: BadgeCountRow[]; queryKey?: readonly ['badges', string]; }; -export function useMarkRead() { +type MarkReadInput = { + sandboxId: string; + conversationId: string; + badgeBucket: string; +}; + +export function useMarkRead(client: KiloChatClient) { const queryClient = useQueryClient(); const userId = useCurrentUserId(); const getToken = useKiloChatTokenGetter(); + const markConversationRead = useMarkConversationRead(client); const mutation = useMutation({ - mutationFn: async (badgeBucket: string): Promise => { - const token = await getToken(); - const input = { badgeBucket } satisfies MarkBadgeReadInput; - const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', + mutationFn: async ({ + conversationId, + badgeBucket, + }: MarkReadInput): Promise => { + const result = await markConversationAndBadgeRead({ + conversationId, + badgeBucket, + markConversationRead: markConversationRead.mutateAsync, + markBadgeRead: async bucket => { + const token = await getToken(); + const input = { badgeBucket: bucket } satisfies MarkBadgeReadInput; + const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new Error(`Failed to mark badge read: ${response.status}`); + } + return markBadgeReadResponseSchema.parse(await response.json()); }, - body: JSON.stringify(input), }); - if (!response.ok) { - throw new Error(`Failed to mark badge read: ${response.status}`); - } - return markBadgeReadResponseSchema.parse(await response.json()); + return result; }, - onMutate: async (badgeBucket): Promise => { + onMutate: async ({ badgeBucket }): Promise => { if (userId === null) { return {}; } @@ -78,7 +99,11 @@ export function useMarkRead() { return useCallback( (sandboxId: string, conversationId: string) => { - mutation.mutate(badgeBucketForConversation(sandboxId, conversationId)); + mutation.mutate({ + sandboxId, + conversationId, + badgeBucket: badgeBucketForConversation(sandboxId, conversationId), + }); }, [mutation] ); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts index ac26bc57f6..6586b465e6 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts @@ -1 +1,21 @@ -export { useMessages, useSendMessage } from '@kilocode/kilo-chat-hooks'; +import { formatKiloChatError, type KiloChatClient } from '@kilocode/kilo-chat'; +import { + useMessageCacheUpdater, + useMessages, + useSendMessage as useSharedSendMessage, +} from '@kilocode/kilo-chat-hooks'; +import { toast } from 'sonner-native'; + +export { useMessages, useMessageCacheUpdater }; + +export function useSendMessage( + client: KiloChatClient, + conversationId: string | null, + currentUserId: string +) { + return useSharedSendMessage(client, conversationId, currentUserId, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to send message')); + }, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts new file mode 100644 index 0000000000..48c9cd58e6 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { type InfiniteData } from '@tanstack/react-query'; +import { type Message, type MessageCreatedEvent } from '@kilocode/kilo-chat'; + +import { applyMessageCreatedEventToPages } from '@kilocode/kilo-chat-hooks'; + +function message(id: string): Message { + return { + id, + senderId: 'user:1', + content: [{ type: 'text', text: id }], + inReplyToMessageId: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; +} + +describe('applyMessageCreatedEventToPages', () => { + it('adds bot-created messages to the open conversation cache', () => { + const data: InfiniteData = { + pages: [[message('existing')]], + pageParams: [undefined], + }; + const event = { + messageId: 'bot-message', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'hello from bot' }], + inReplyToMessageId: null, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.map(m => m.id)).toEqual(['bot-message', 'existing']); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-order.test.ts b/apps/mobile/src/components/kilo-chat/message-list-order.test.ts new file mode 100644 index 0000000000..3537b6d0b4 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-order.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { type Message } from '@kilocode/kilo-chat'; + +import { getFlashListMessages } from './message-list-order'; + +function message(id: string): Message { + return { + id, + senderId: 'user:1', + content: [{ type: 'text', text: id }], + inReplyToMessageId: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; +} + +describe('getFlashListMessages', () => { + it('keeps oldest-to-newest messages in chronological order', () => { + const messages = [message('oldest'), message('middle'), message('newest')]; + + expect(getFlashListMessages(messages).map(m => m.id)).toEqual(['oldest', 'middle', 'newest']); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-order.ts b/apps/mobile/src/components/kilo-chat/message-list-order.ts new file mode 100644 index 0000000000..123faf96d3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-order.ts @@ -0,0 +1,5 @@ +import { type Message } from '@kilocode/kilo-chat'; + +export function getFlashListMessages(messages: Message[]): Message[] { + return messages; +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index b418e76961..a1aef5fef7 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -5,6 +5,8 @@ import { View } from 'react-native'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; import { Skeleton } from '@/components/ui/skeleton'; +import { getFlashListMessages } from './message-list-order'; + type Props = { messages: Message[]; conversationId: string; @@ -22,11 +24,10 @@ export function MessageList({ hasOlder, onLongPressMessage, }: Props) { - // useMessages returns messages newest-first (result of .reverse() in the hook). + // useMessages returns messages oldest-to-newest. // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition - // with startRenderingFromBottom. That requires data in chronological order (oldest first), - // so we reverse once to get oldest→newest. - const chronological = messages.toReversed(); + // with startRenderingFromBottom, which expects chronological order. + const chronological = getFlashListMessages(messages); return ( { - router.replace(`/claw/kilo-chat?sandboxId=${sandboxId}`); - }, [router, sandboxId]); + router.replace(buildKiloChatRedirect(basePath, sandboxId)); + }, [router, sandboxId, basePath]); return null; } diff --git a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx index a9ad8bc409..59b2e02a98 100644 --- a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx @@ -56,12 +56,15 @@ function ClawChatWithStatus({ organizationId }: { organizationId?: string }) { if (!status || status.status === null) return null; + const kiloChatBasePath = organizationId + ? `/organizations/${organizationId}/claw/kilo-chat` + : '/claw/kilo-chat'; const chatContent = ( <> - + diff --git a/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts b/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts new file mode 100644 index 0000000000..210a049fdf --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts @@ -0,0 +1,9 @@ +import { buildKiloChatRedirect } from './chat-redirect'; + +describe('buildKiloChatRedirect', () => { + it('preserves organization kilo-chat base paths', () => { + expect(buildKiloChatRedirect('/organizations/org-1/claw/kilo-chat', 'sandbox/with space')).toBe( + '/organizations/org-1/claw/kilo-chat?sandboxId=sandbox%2Fwith%20space' + ); + }); +}); diff --git a/apps/web/src/app/(app)/claw/components/chat-redirect.ts b/apps/web/src/app/(app)/claw/components/chat-redirect.ts new file mode 100644 index 0000000000..a2259fe648 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/chat-redirect.ts @@ -0,0 +1,3 @@ +export function buildKiloChatRedirect(basePath: string, sandboxId: string): string { + return `${basePath}?sandboxId=${encodeURIComponent(sandboxId)}`; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index ab8933d548..03be25a218 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { formatKiloChatError } from '@kilocode/kilo-chat'; import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; +import { shouldApplyConversationRead } from './conversation-read-events'; import { kiloclawInstanceContext } from '@kilocode/event-service'; import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useEventServiceClient } from '@/contexts/EventServiceContext'; @@ -103,7 +104,7 @@ export function KiloChatLayout({ // `memberId` of whose read-marker moved. Only the actual reader // should see their own sidebar row's `lastReadAt` advance — without // this filter, Alice marking read would also move Bob's `lastReadAt`. - if (e.memberId !== currentUserId) return; + if (!shouldApplyConversationRead(currentUserId, e.memberId)) return; queryClient.setQueriesData({ queryKey }, old => updateConversationPages(old, c => c.conversationId === e.conversationId ? { ...c, lastReadAt: e.lastReadAt } : c @@ -123,7 +124,7 @@ export function KiloChatLayout({ }), ]; return () => offs.forEach(off => off()); - }, [kiloChatClient, queryClient]); + }, [kiloChatClient, queryClient, currentUserId]); // Refetch conversations on WebSocket reconnect (events may have been missed) useEffect(() => { diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.test.ts new file mode 100644 index 0000000000..ab40b85ef0 --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.test.ts @@ -0,0 +1,9 @@ +import { shouldApplyConversationRead } from './conversation-read-events'; + +describe('shouldApplyConversationRead', () => { + it('waits for the current user id before applying own read events', () => { + expect(shouldApplyConversationRead('', 'user-1')).toBe(false); + expect(shouldApplyConversationRead('user-1', 'user-1')).toBe(true); + expect(shouldApplyConversationRead('user-1', 'user-2')).toBe(false); + }); +}); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.ts new file mode 100644 index 0000000000..5944608678 --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-read-events.ts @@ -0,0 +1,3 @@ +export function shouldApplyConversationRead(currentUserId: string, eventMemberId: string): boolean { + return currentUserId !== '' && eventMemberId === currentUserId; +} diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 430e797fc0..4079d7b9da 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -7,6 +7,10 @@ import { conversationKey, conversationsKey, conversationsKeyAll, messagesKey } f const CONVERSATIONS_PAGE_SIZE = 50; +type MutationErrorOptions = { + onError?: (error: unknown) => void; +}; + export function useConversations(client: KiloChatClient, sandboxId: string | null) { return useInfiniteQuery({ queryKey: conversationsKey(sandboxId), @@ -34,13 +38,14 @@ export function useConversationDetail(client: KiloChatClient, conversationId: st }); } -export function useCreateConversation(client: KiloChatClient) { +export function useCreateConversation(client: KiloChatClient, options?: MutationErrorOptions) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (req: CreateConversationRequest) => client.createConversation(req), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); }, + onError: options?.onError, }); } diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index b2302e1903..fea97b3a31 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -130,10 +130,55 @@ export function useMessages(client: KiloChatClient, conversationId: string | nul export type SendMessageVariables = CreateMessageRequest & { clientId: string }; +type MutationErrorOptions = { + onError?: (error: unknown) => void; +}; + +export function messageFromCreatedEvent(e: MessageCreatedEvent): Message { + return { + id: e.messageId, + senderId: e.senderId, + content: e.content, + inReplyToMessageId: e.inReplyToMessageId, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + }; +} + +export function applyMessageCreatedEventToPages( + old: InfiniteData, + e: MessageCreatedEvent +): InfiniteData { + for (const page of old.pages) { + if (page.some(msg => msg.id === e.messageId)) return old; + } + + const newMessage = messageFromCreatedEvent(e); + + if (e.clientId) { + const pendingId = `pending-${e.clientId}`; + for (const page of old.pages) { + if (page.some(msg => msg.id === pendingId)) { + return { + ...old, + pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))), + }; + } + } + } + + const firstPage = old.pages[0] ?? []; + return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] }; +} + export function useSendMessage( client: KiloChatClient, conversationId: string | null, - currentUserId: string + currentUserId: string, + options?: MutationErrorOptions ) { const queryClient = useQueryClient(); return useMutation({ @@ -174,9 +219,10 @@ export function useSendMessage( }; }); }, - onError: (_err, _variables, context) => { + onError: (err, _variables, context) => { if (!context) return; removeMessageFromCache(queryClient, context.queryKey, context.pendingId); + options?.onError?.(err); }, }); } @@ -415,37 +461,9 @@ export function useMessageCacheUpdater( if (!e.senderId.startsWith('bot:')) { onHumanMessageCreated?.(ctx, e.senderId); } - const newMessage: Message = { - id: e.messageId, - senderId: e.senderId, - content: e.content, - inReplyToMessageId: e.inReplyToMessageId, - updatedAt: null, - clientUpdatedAt: null, - deleted: false, - deliveryFailed: false, - reactions: [], - }; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - // Skip if this messageId already exists - for (const page of old.pages) { - if (page.some(msg => msg.id === e.messageId)) return old; - } - // Replace the matching pending optimistic message if clientId correlates - if (e.clientId) { - const pendingId = `pending-${e.clientId}`; - for (const page of old.pages) { - if (page.some(msg => msg.id === pendingId)) { - return { - ...old, - pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))), - }; - } - } - } - const firstPage = old.pages[0] ?? []; - return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] }; + return applyMessageCreatedEventToPages(old, e); }); }; From d4fdde40160b6c57abc9cfe551154ff4ead1771c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:11:09 +0200 Subject: [PATCH 026/289] perf(mobile): hoist kilo chat row mutations --- .../kilo-chat/conversation-screen.tsx | 30 +++++++++++++- .../components/kilo-chat/message-bubble.tsx | 41 +++++++------------ .../src/components/kilo-chat/message-list.tsx | 15 +++++-- 3 files changed, 55 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index c4080009de..02b6e17163 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,3 +1,5 @@ +import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; import * as Crypto from 'expo-crypto'; import { useCallback } from 'react'; import { KeyboardAvoidingView, Platform, View } from 'react-native'; @@ -32,6 +34,9 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [messagesQuery]); const sendMutation = useSendMessage(client, conversationId, currentUserId ?? ''); + const executeAction = useExecuteAction(client, conversationId, currentUserId ?? ''); + const addReaction = useAddReaction(client, conversationId, currentUserId ?? ''); + const removeReaction = useRemoveReaction(client, conversationId, currentUserId ?? ''); const handleSend = useCallback( (text: string) => { sendMutation.mutate({ @@ -42,6 +47,27 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [sendMutation, conversationId] ); + const handleReactionPress = useCallback( + (message: Message, emoji: string) => { + if (!currentUserId) { + return; + } + const hasReacted = + message.reactions.find(r => r.emoji === emoji)?.memberIds.includes(currentUserId) ?? false; + if (hasReacted) { + removeReaction.mutate({ messageId: message.id, emoji }); + } else { + addReaction.mutate({ messageId: message.id, emoji }); + } + }, + [addReaction, currentUserId, removeReaction] + ); + const handleExecuteAction = useCallback( + (message: Message, groupId: string, value: ExecApprovalDecision) => { + executeAction.mutate({ messageId: message.id, groupId, value }); + }, + [executeAction] + ); useConversationPresence(sandboxId, conversationId); useConversationEventSubscription(sandboxId, conversationId); @@ -70,10 +96,12 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl > diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index 211264261a..2f81413229 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -1,18 +1,19 @@ -import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { memo } from 'react'; import { Pressable, View } from 'react-native'; -import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; -import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; type Props = { message: Message; - conversationId: string; + currentUserId: string | null; isFromMe: boolean; showAuthor: boolean; + isExecutingAction: boolean; + onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; + onReactionPress: (message: Message, emoji: string) => void; onLongPress?: (m: Message) => void; }; @@ -32,39 +33,25 @@ function actionStyleToVariant( return 'default'; } -export function MessageBubble({ +function MessageBubbleComponent({ message, - conversationId, + currentUserId, isFromMe, showAuthor, + isExecutingAction, + onExecuteAction, + onReactionPress, onLongPress, }: Props) { - const client = useKiloChatClient(); - const currentUserId = useCurrentUserId(); - - const executeAction = useExecuteAction(client, conversationId, currentUserId ?? ''); - const addReaction = useAddReaction(client, conversationId, currentUserId ?? ''); - const removeReaction = useRemoveReaction(client, conversationId, currentUserId ?? ''); - const isPending = message.id.startsWith('pending-'); const timestamp = message.clientUpdatedAt ?? message.updatedAt; function handleReactionPress(emoji: string) { - if (!currentUserId) { - return; - } - const hasReacted = message.reactions - .find(r => r.emoji === emoji) - ?.memberIds.includes(currentUserId); - if (hasReacted) { - removeReaction.mutate({ messageId: message.id, emoji }); - } else { - addReaction.mutate({ messageId: message.id, emoji }); - } + onReactionPress(message, emoji); } function handleExecuteAction(groupId: string, value: ExecApprovalDecision) { - executeAction.mutate({ messageId: message.id, groupId, value }); + onExecuteAction(message, groupId, value); } const textColor = isFromMe ? 'text-primary-foreground' : 'text-foreground'; @@ -126,7 +113,7 @@ export function MessageBubble({ key={action.value} variant={actionStyleToVariant(action.style)} size="sm" - disabled={executeAction.isPending} + disabled={isExecutingAction} onPress={() => { handleExecuteAction(block.groupId, action.value); }} @@ -189,3 +176,5 @@ export function MessageBubble({ ); } + +export const MessageBubble = memo(MessageBubbleComponent); diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index a1aef5fef7..d3d37cdd78 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -1,5 +1,5 @@ import { FlashList } from '@shopify/flash-list'; -import { type Message } from '@kilocode/kilo-chat'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; import { View } from 'react-native'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; @@ -9,19 +9,23 @@ import { getFlashListMessages } from './message-list-order'; type Props = { messages: Message[]; - conversationId: string; currentUserId: string | null; fetchOlder?: () => void; hasOlder?: boolean; + isExecutingAction: boolean; + onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; + onReactionPress: (message: Message, emoji: string) => void; onLongPressMessage?: (m: Message) => void; }; export function MessageList({ messages, - conversationId, currentUserId, fetchOlder, hasOlder, + isExecutingAction, + onExecuteAction, + onReactionPress, onLongPressMessage, }: Props) { // useMessages returns messages oldest-to-newest. @@ -42,9 +46,12 @@ export function MessageList({ return ( ); From 9dc40048c1cb1d56f78d0177cf618a3eeb45b24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:12:12 +0200 Subject: [PATCH 027/289] perf(kilo-chat): update target message pages --- .../kilo-chat/live-message-cache.test.ts | 32 ++- packages/kilo-chat-hooks/src/use-messages.ts | 240 ++++++------------ 2 files changed, 113 insertions(+), 159 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts index 48c9cd58e6..263e5e32fe 100644 --- a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts +++ b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { type InfiniteData } from '@tanstack/react-query'; import { type Message, type MessageCreatedEvent } from '@kilocode/kilo-chat'; -import { applyMessageCreatedEventToPages } from '@kilocode/kilo-chat-hooks'; +import { applyMessageCreatedEventToPages, updateMessageInPages } from '@kilocode/kilo-chat-hooks'; function message(id: string): Message { return { @@ -37,3 +37,33 @@ describe('applyMessageCreatedEventToPages', () => { expect(result.pages[0]?.map(m => m.id)).toEqual(['bot-message', 'existing']); }); }); + +describe('updateMessageInPages', () => { + it('returns the same cache object when the target message is absent', () => { + const data: InfiniteData = { + pages: [[message('m1')], [message('m2')]], + pageParams: [undefined, 'm1'], + }; + + const result = updateMessageInPages(data, 'missing', msg => ({ ...msg, deleted: true })); + + expect(result).toBe(data); + }); + + it('copies only the pages array and containing page when updating a message', () => { + const firstPage = [message('m1')]; + const secondPage = [message('m2')]; + const data: InfiniteData = { + pages: [firstPage, secondPage], + pageParams: [undefined, 'm1'], + }; + + const result = updateMessageInPages(data, 'm2', msg => ({ ...msg, deleted: true })); + + expect(result).not.toBe(data); + expect(result.pages).not.toBe(data.pages); + expect(result.pages[0]).toBe(firstPage); + expect(result.pages[1]).not.toBe(secondPage); + expect(result.pages[1]?.[0]?.deleted).toBe(true); + }); +}); diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index fea97b3a31..1c8c689061 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -63,16 +63,7 @@ export function restoreMessageInCache( ): void { queryClient.setQueryData>(queryKey, old => { if (!old) return old; - let replaced = false; - const pages = old.pages.map(page => - page.map(msg => { - if (msg.id !== snapshot.id) return msg; - replaced = true; - return snapshot; - }) - ); - if (!replaced) return old; - return { ...old, pages }; + return updateMessageInPages(old, snapshot.id, () => snapshot); }); } @@ -109,6 +100,28 @@ export function findMessageInCache( return undefined; } +export function updateMessageInPages( + old: InfiniteData, + messageId: string, + updater: (message: Message) => Message +): InfiniteData { + for (let pageIndex = 0; pageIndex < old.pages.length; pageIndex++) { + const page = old.pages[pageIndex]; + if (!page) continue; + const messageIndex = page.findIndex(msg => msg.id === messageId); + if (messageIndex === -1) continue; + + const pages = old.pages.slice(); + const updatedPage = page.slice(); + const message = updatedPage[messageIndex]; + if (!message) return old; + updatedPage[messageIndex] = updater(message); + pages[pageIndex] = updatedPage; + return { ...old, pages }; + } + return old; +} + export function useMessages(client: KiloChatClient, conversationId: string | null) { return useInfiniteQuery({ queryKey: messagesKey(conversationId), @@ -152,22 +165,16 @@ export function applyMessageCreatedEventToPages( old: InfiniteData, e: MessageCreatedEvent ): InfiniteData { - for (const page of old.pages) { - if (page.some(msg => msg.id === e.messageId)) return old; - } - const newMessage = messageFromCreatedEvent(e); if (e.clientId) { const pendingId = `pending-${e.clientId}`; - for (const page of old.pages) { - if (page.some(msg => msg.id === pendingId)) { - return { - ...old, - pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))), - }; - } - } + const replacedPending = updateMessageInPages(old, pendingId, () => newMessage); + if (replacedPending !== old) return replacedPending; + } + + for (const page of old.pages) { + if (page.some(msg => msg.id === e.messageId)) return old; } const firstPage = old.pages[0] ?? []; @@ -211,12 +218,7 @@ export function useSendMessage( const { queryKey, pendingId } = context; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === pendingId ? { ...msg, id: response.messageId } : msg)) - ), - }; + return updateMessageInPages(old, pendingId, msg => ({ ...msg, id: response.messageId })); }); }, onError: (err, _variables, context) => { @@ -239,16 +241,11 @@ export function useEditMessage(client: KiloChatClient, conversationId: string | const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id === variables.messageId - ? { ...msg, content: variables.content, clientUpdatedAt: variables.timestamp } - : msg - ) - ), - }; + return updateMessageInPages(old, variables.messageId, msg => ({ + ...msg, + content: variables.content, + clientUpdatedAt: variables.timestamp, + })); }); return { queryKey, snapshot }; }, @@ -271,12 +268,7 @@ export function useDeleteMessage(client: KiloChatClient, conversationId: string const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === variables.messageId ? { ...msg, deleted: true } : msg)) - ), - }; + return updateMessageInPages(old, variables.messageId, msg => ({ ...msg, deleted: true })); }); return { queryKey, snapshot }; }, @@ -303,19 +295,10 @@ export function useAddReaction( const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== variables.messageId - ? msg - : { - ...msg, - reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId), - } - ) - ), - }; + return updateMessageInPages(old, variables.messageId, msg => ({ + ...msg, + reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId), + })); }); return { queryKey, snapshot }; }, @@ -342,19 +325,10 @@ export function useRemoveReaction( const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== variables.messageId - ? msg - : { - ...msg, - reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId), - } - ) - ), - }; + return updateMessageInPages(old, variables.messageId, msg => ({ + ...msg, + reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId), + })); }); return { queryKey, snapshot }; }, @@ -389,29 +363,21 @@ export function useExecuteAction( // Optimistically mark the action as resolved queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => { - if (msg.id !== variables.messageId) return msg; - return { - ...msg, - content: msg.content.map(block => { - if (block.type !== 'actions') return block; - if (block.groupId !== variables.groupId) return block; - return { - ...block, - resolved: { - value: variables.value, - resolvedBy: currentUserId, - resolvedAt: Date.now(), - }, - }; - }), - }; - }) - ), - }; + return updateMessageInPages(old, variables.messageId, msg => ({ + ...msg, + content: msg.content.map(block => { + if (block.type !== 'actions') return block; + if (block.groupId !== variables.groupId) return block; + return { + ...block, + resolved: { + value: variables.value, + resolvedBy: currentUserId, + resolvedAt: Date.now(), + }, + }; + }), + })); }); return { queryKey, snapshot }; }, @@ -471,20 +437,11 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id === e.messageId - ? { - ...msg, - content: e.content, - clientUpdatedAt: e.clientUpdatedAt, - } - : msg - ) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ + ...msg, + content: e.content, + clientUpdatedAt: e.clientUpdatedAt, + })); }); }; @@ -492,12 +449,7 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === e.messageId ? { ...msg, deleted: true } : msg)) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ ...msg, deleted: true })); }); }; @@ -505,12 +457,7 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => (msg.id === e.messageId ? { ...msg, deliveryFailed: true } : msg)) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ ...msg, deliveryFailed: true })); }); }; @@ -518,22 +465,14 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => { - if (msg.id !== e.messageId) return msg; - return { - ...msg, - content: msg.content.map(block => { - if (block.type !== 'actions') return block; - if (block.groupId !== e.groupId) return block; - return { ...block, resolved: undefined }; - }), - }; - }) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ + ...msg, + content: msg.content.map(block => { + if (block.type !== 'actions') return block; + if (block.groupId !== e.groupId) return block; + return { ...block, resolved: undefined }; + }), + })); }); onActionFailed?.(); }; @@ -542,16 +481,10 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== e.messageId - ? msg - : { ...msg, reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId) } - ) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ + ...msg, + reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId), + })); }); }; @@ -559,19 +492,10 @@ export function useMessageCacheUpdater( if (ctx !== expectedContext) return; queryClient.setQueryData>(queryKey, old => { if (!old) return old; - return { - ...old, - pages: old.pages.map(page => - page.map(msg => - msg.id !== e.messageId - ? msg - : { - ...msg, - reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId), - } - ) - ), - }; + return updateMessageInPages(old, e.messageId, msg => ({ + ...msg, + reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId), + })); }); }; From 75e46f9f436e406cd218c118ad7030e6edc6682b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:14:05 +0200 Subject: [PATCH 028/289] perf(mobile): patch kilo chat list events --- .../kilo-chat/hooks/instance-event-cache.ts | 19 +++ .../hooks/use-instance-event-subscription.ts | 141 ++++++++++++++++-- .../kilo-chat/instance-event-cache.test.ts | 39 +++++ 3 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/instance-event-cache.ts create mode 100644 apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts diff --git a/apps/mobile/src/components/kilo-chat/hooks/instance-event-cache.ts b/apps/mobile/src/components/kilo-chat/hooks/instance-event-cache.ts new file mode 100644 index 0000000000..ea630ca7f7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/instance-event-cache.ts @@ -0,0 +1,19 @@ +import { type ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; + +export function isConversationOnFirstPage( + data: ConversationListInfiniteData | undefined, + conversationId: string +): boolean { + return ( + data?.pages[0]?.conversations.some( + conversation => conversation.conversationId === conversationId + ) ?? false + ); +} + +export function shouldApplyConversationRead( + currentUserId: string | null, + memberId: string +): boolean { + return currentUserId !== null && currentUserId === memberId; +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts index 8b6f57449e..de29ac0fd1 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts @@ -1,31 +1,152 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { kiloclawInstanceContext } from '@kilocode/event-service'; -import { botStatusKey, conversationsKey } from '@kilocode/kilo-chat-hooks'; +import { + conversationActivityEventSchema, + conversationCreatedEventSchema, + conversationLeftEventSchema, + conversationReadEventSchema, + conversationRenamedEventSchema, +} from '@kilocode/kilo-chat'; +import { + botStatusKey, + conversationKey, + type ConversationListInfiniteData, + conversationsKey, + filterConversationPages, + updateConversationPages, +} from '@kilocode/kilo-chat-hooks'; +import { isConversationOnFirstPage, shouldApplyConversationRead } from './instance-event-cache'; import { useEventSubscription } from './use-event-subscription'; +import { useCurrentUserId } from './use-current-user-id'; +import { useEventServiceClient } from './use-kilo-chat-client'; export function useInstanceEventSubscription(sandboxId: string | undefined) { const qc = useQueryClient(); + const eventService = useEventServiceClient(); + const currentUserId = useCurrentUserId(); const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null; + const queryKey = useMemo(() => conversationsKey(sandboxId ?? null), [sandboxId]); // conversation.* events are published on the instance context to keep the // conversation list (last-activity, unread, title, membership) current while // the user is on the list. message.* events fire on conversation contexts, // not here. - const invalidateConversations = useCallback(() => { - void qc.invalidateQueries({ queryKey: conversationsKey(sandboxId ?? null) }); - }, [qc, sandboxId]); + const handleCreated = useCallback( + (payload: unknown) => { + const result = conversationCreatedEventSchema.safeParse(payload); + if (!result.success) { + return; + } + const event = result.data; + const data = qc.getQueryData(queryKey); + if (isConversationOnFirstPage(data, event.conversationId)) { + return; + } + void qc.invalidateQueries({ queryKey }); + }, + [qc, queryKey] + ); + + const handleLeft = useCallback( + (payload: unknown) => { + const result = conversationLeftEventSchema.safeParse(payload); + if (!result.success) { + return; + } + const event = result.data; + qc.setQueryData(queryKey, old => + filterConversationPages( + old, + conversation => conversation.conversationId !== event.conversationId + ) + ); + }, + [qc, queryKey] + ); + + const handleRenamed = useCallback( + (payload: unknown) => { + const result = conversationRenamedEventSchema.safeParse(payload); + if (!result.success) { + return; + } + const event = result.data; + qc.setQueryData(queryKey, old => + updateConversationPages(old, conversation => + conversation.conversationId === event.conversationId + ? { ...conversation, title: event.title } + : conversation + ) + ); + void qc.invalidateQueries({ queryKey: conversationKey(event.conversationId) }); + }, + [qc, queryKey] + ); + + const handleRead = useCallback( + (payload: unknown) => { + const result = conversationReadEventSchema.safeParse(payload); + if (!result.success) { + return; + } + const event = result.data; + if (!shouldApplyConversationRead(currentUserId, event.memberId)) { + return; + } + qc.setQueryData(queryKey, old => + updateConversationPages(old, conversation => + conversation.conversationId === event.conversationId + ? { ...conversation, lastReadAt: event.lastReadAt } + : conversation + ) + ); + }, + [currentUserId, qc, queryKey] + ); + + const handleActivity = useCallback( + (payload: unknown) => { + const result = conversationActivityEventSchema.safeParse(payload); + if (!result.success) { + return; + } + const event = result.data; + const data = qc.getQueryData(queryKey); + if (!isConversationOnFirstPage(data, event.conversationId)) { + void qc.invalidateQueries({ queryKey }); + return; + } + qc.setQueryData(queryKey, old => + updateConversationPages(old, conversation => + conversation.conversationId === event.conversationId + ? { ...conversation, lastActivityAt: event.lastActivityAt } + : conversation + ) + ); + }, + [qc, queryKey] + ); const invalidateBotStatus = useCallback(() => { void qc.invalidateQueries({ queryKey: botStatusKey(sandboxId ?? null) }); }, [qc, sandboxId]); - useEventSubscription(ctx, 'conversation.created', invalidateConversations); - useEventSubscription(ctx, 'conversation.left', invalidateConversations); - useEventSubscription(ctx, 'conversation.renamed', invalidateConversations); - useEventSubscription(ctx, 'conversation.read', invalidateConversations); - useEventSubscription(ctx, 'conversation.activity', invalidateConversations); + useEventSubscription(ctx, 'conversation.created', handleCreated); + useEventSubscription(ctx, 'conversation.left', handleLeft); + useEventSubscription(ctx, 'conversation.renamed', handleRenamed); + useEventSubscription(ctx, 'conversation.read', handleRead); + useEventSubscription(ctx, 'conversation.activity', handleActivity); useEventSubscription(ctx, 'bot.status', invalidateBotStatus); + + useEffect(() => { + if (!sandboxId) { + return undefined; + } + return eventService.onReconnect(() => { + void qc.invalidateQueries({ queryKey }); + }); + }, [eventService, qc, queryKey, sandboxId]); } diff --git a/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts b/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts new file mode 100644 index 0000000000..dcf3b63aa0 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts @@ -0,0 +1,39 @@ +import { type ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; +import { describe, expect, it } from 'vitest'; + +import { + isConversationOnFirstPage, + shouldApplyConversationRead, +} from './hooks/instance-event-cache'; + +function conversation(conversationId: string) { + return { + conversationId, + title: null, + lastActivityAt: null, + lastReadAt: null, + joinedAt: 1, + }; +} + +describe('instance event cache helpers', () => { + it('only treats conversations in the first loaded page as locally patchable for created/activity', () => { + const data: ConversationListInfiniteData = { + pages: [ + { conversations: [conversation('first')], hasMore: true, nextCursor: 'cursor-1' }, + { conversations: [conversation('second')], hasMore: false, nextCursor: null }, + ], + pageParams: [null, 'cursor-1'], + }; + + expect(isConversationOnFirstPage(data, 'first')).toBe(true); + expect(isConversationOnFirstPage(data, 'second')).toBe(false); + expect(isConversationOnFirstPage(undefined, 'first')).toBe(false); + }); + + it('applies conversation.read only for the current user', () => { + expect(shouldApplyConversationRead('reader', 'reader')).toBe(true); + expect(shouldApplyConversationRead('reader', 'other')).toBe(false); + expect(shouldApplyConversationRead(null, 'reader')).toBe(false); + }); +}); From 067f3c6be02007589269e39313ff42086c1f7df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:15:33 +0200 Subject: [PATCH 029/289] perf(kilo-chat): target read instance events --- .../src/__tests__/event-push.test.ts | 23 +++++++++++++++ .../kilo-chat/src/services/conversations.ts | 11 ++++++-- services/kilo-chat/src/services/event-push.ts | 28 +++++++++++++++++++ services/kilo-chat/src/services/messages.ts | 8 +++--- 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 services/kilo-chat/src/__tests__/event-push.test.ts diff --git a/services/kilo-chat/src/__tests__/event-push.test.ts b/services/kilo-chat/src/__tests__/event-push.test.ts new file mode 100644 index 0000000000..60af12f6c7 --- /dev/null +++ b/services/kilo-chat/src/__tests__/event-push.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { pushInstanceEventToUser } from '../services/event-push'; + +describe('pushInstanceEventToUser', () => { + it('pushes an instance-context event only to the targeted user', async () => { + const pushEvent = vi.fn().mockResolvedValue(false); + const env = { EVENT_SERVICE: { pushEvent } } as unknown as Env; + + await pushInstanceEventToUser(env, 'sandbox-1', 'reader-1', 'conversation.read', { + conversationId: 'conversation-1', + memberId: 'reader-1', + lastReadAt: 123, + }); + + expect(pushEvent).toHaveBeenCalledOnce(); + expect(pushEvent).toHaveBeenCalledWith('reader-1', '/kiloclaw/sandbox-1', 'conversation.read', { + conversationId: 'conversation-1', + memberId: 'reader-1', + lastReadAt: 123, + }); + }); +}); diff --git a/services/kilo-chat/src/services/conversations.ts b/services/kilo-chat/src/services/conversations.ts index 1914467860..966c7194d0 100644 --- a/services/kilo-chat/src/services/conversations.ts +++ b/services/kilo-chat/src/services/conversations.ts @@ -5,7 +5,12 @@ import { ulid } from 'ulid'; import { withDORetry } from '@kilocode/worker-utils'; -import { extractConversationContext, extractSandboxId, pushInstanceEvent } from './event-push'; +import { + extractConversationContext, + extractSandboxId, + pushInstanceEvent, + pushInstanceEventToUser, +} from './event-push'; import { lookupSandboxOwnerUserId, userOwnsSandbox } from './sandbox-ownership'; import { validateUserIds } from './user-lookup'; import type { DeferCtx } from './messages'; @@ -351,9 +356,9 @@ export async function markReadFor( 'MembershipDO.markRead' ); - const { humanMemberIds, sandboxId } = extractConversationContext(info.members); + const { sandboxId } = extractConversationContext(info.members); if (sandboxId) { - const pushPromise = pushInstanceEvent(env, sandboxId, humanMemberIds, 'conversation.read', { + const pushPromise = pushInstanceEventToUser(env, sandboxId, userId, 'conversation.read', { conversationId, memberId: userId, lastReadAt: now, diff --git a/services/kilo-chat/src/services/event-push.ts b/services/kilo-chat/src/services/event-push.ts index a1491c25f8..79071123ed 100644 --- a/services/kilo-chat/src/services/event-push.ts +++ b/services/kilo-chat/src/services/event-push.ts @@ -84,6 +84,34 @@ export async function pushInstanceEvent( } } +/** + * Pushes an event on the instance-level context to one user. Used for + * user-specific events such as read markers where other members must ignore + * the payload. + */ +export async function pushInstanceEventToUser( + env: Env, + sandboxId: string, + userId: string, + event: N, + payload: KiloChatEventOf +): Promise { + const es = getEventService(env); + if (!es) return; + const context = kiloclawInstanceContext(sandboxId); + + try { + await es.pushEvent(userId, context, event, payload); + } catch (err) { + logger.error('event-service pushEvent failed for instance user', { + userId, + sandboxId, + event, + ...formatError(err), + }); + } +} + /** * Resolves the sandbox owner, persists the heartbeat to `SandboxStatusDO`, and * pushes a `bot.status` event to the owner on the instance-level context. diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 884b31de1e..1bca6d9ec4 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -15,6 +15,7 @@ import { extractConversationContext, pushEventToHumanMembers, pushInstanceEvent, + pushInstanceEventToUser, } from './event-push'; import { fetchSandboxLabel } from './sandbox-lookup'; import type { ConversationInfo } from '../do/conversation-do'; @@ -283,9 +284,8 @@ async function postCommitFanOut( // event so their sidebar row's `lastActivityAt` advances across tabs. // Independently, anyone who has "read" this message (the sender, who // authored it, or a recipient whose WS subscribed to the conversation - // context and delivered `message.created`) gets a `conversation.read` - // with their own `memberId`. The client filters `.read` by memberId so - // Alice's read marker never leaks into Bob's sidebar. + // context and delivered `message.created`) gets a targeted + // `conversation.read` with their own `memberId`. instanceEvents.push( pushInstanceEvent(env, sandboxId, humanMemberIds, 'conversation.activity', { conversationId, @@ -297,7 +297,7 @@ async function postCommitFanOut( const present = deliveryMap.get(userId) === true; if (!isSender && !present) continue; instanceEvents.push( - pushInstanceEvent(env, sandboxId, humanMemberIds, 'conversation.read', { + pushInstanceEventToUser(env, sandboxId, userId, 'conversation.read', { conversationId, memberId: userId, lastReadAt: now, From c7cf6d5995a2a882d160551b992d15e1077113b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:16:41 +0200 Subject: [PATCH 030/289] perf(notifications): parallelize chat push fanout --- .../send-push-for-conversation.test.ts | 37 ++++++++++++ services/notifications/src/index.ts | 58 +++++++++++-------- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/services/notifications/src/__tests__/send-push-for-conversation.test.ts b/services/notifications/src/__tests__/send-push-for-conversation.test.ts index 2296400b8a..e43c09597f 100644 --- a/services/notifications/src/__tests__/send-push-for-conversation.test.ts +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -7,6 +7,7 @@ import type { } from '@kilocode/notifications'; import type * as do_module from '../dos/NotificationChannelDO'; +import { sendPushForConversationCore } from '../index'; const baseInput = ( over: Partial = {} @@ -73,4 +74,40 @@ describe('NotificationsService.sendPushForConversation', () => { messageId: 'm1', }); }); + + it('dispatches recipients in parallel while preserving output order', async () => { + const dispatches = new Map< + string, + (outcome: { kind: 'delivered'; tokenCount: number }) => void + >(); + const dispatchOrder: string[] = []; + const resultPromise = sendPushForConversationCore( + baseInput({ recipientUserIds: ['r1', 'r2', 'r3'], senderUserId: null }), + { + getRecipientDOStub: userId => ({ + dispatchPush: async () => { + dispatchOrder.push(userId); + return new Promise(resolve => { + dispatches.set(userId, resolve); + }); + }, + }), + } + ); + + await Promise.resolve(); + + expect(dispatchOrder).toEqual(['r1', 'r2', 'r3']); + + dispatches.get('r2')?.({ kind: 'delivered', tokenCount: 1 }); + dispatches.get('r1')?.({ kind: 'delivered', tokenCount: 1 }); + dispatches.get('r3')?.({ kind: 'delivered', tokenCount: 1 }); + + const result = await resultPromise; + expect(result.perRecipient).toEqual([ + { userId: 'r1', outcome: 'delivered' }, + { userId: 'r2', outcome: 'delivered' }, + { userId: 'r3', outcome: 'delivered' }, + ]); + }); }); diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 9d93166918..0c00956795 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -102,32 +102,40 @@ export async function sendPushForConversationCore( recipients.push(id); } - const perRecipient: PerRecipientResult[] = []; - for (const userId of recipients) { - const stub = deps.getRecipientDOStub(userId); - const outcome = await stub.dispatchPush({ - userId, - presenceContext: presenceContextForConversation(input.sandboxId, input.conversationId), - idempotencyKey: `chat:${input.messageId}:${userId}`, - badge: { - badgeBucket: badgeBucketForConversation(input.sandboxId, input.conversationId), - delta: 1, - }, - push: { - title: input.title, - body: input.bodyPreview, - data: { - type: 'chat.message', - sandboxId: input.sandboxId, - conversationId: input.conversationId, - messageId: input.messageId, + const results = await Promise.allSettled( + recipients.map(async userId => { + const stub = deps.getRecipientDOStub(userId); + const outcome = await stub.dispatchPush({ + userId, + presenceContext: presenceContextForConversation(input.sandboxId, input.conversationId), + idempotencyKey: `chat:${input.messageId}:${userId}`, + badge: { + badgeBucket: badgeBucketForConversation(input.sandboxId, input.conversationId), + delta: 1, }, - sound: 'default', - priority: 'high', - }, - }); - perRecipient.push({ userId, outcome: outcome.kind }); - } + push: { + title: input.title, + body: input.bodyPreview, + data: { + type: 'chat.message', + sandboxId: input.sandboxId, + conversationId: input.conversationId, + messageId: input.messageId, + }, + sound: 'default', + priority: 'high', + }, + }); + return outcome.kind; + }) + ); + const perRecipient: PerRecipientResult[] = recipients.map((userId, index) => { + const result = results[index]; + return { + userId, + outcome: result?.status === 'fulfilled' ? result.value : 'failed', + }; + }); return { perRecipient }; } From 14475f77b92a316c89afc377518e3295a1f293e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:18:29 +0200 Subject: [PATCH 031/289] perf(notifications): track badge total aggregate --- .../src/__tests__/badge-storage.test.ts | 8 +++++ .../src/__tests__/dispatch-push.test.ts | 9 +++--- .../src/dos/NotificationChannelDO.ts | 29 ++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/services/notifications/src/__tests__/badge-storage.test.ts b/services/notifications/src/__tests__/badge-storage.test.ts index e0cc581b3e..5d6bd016a9 100644 --- a/services/notifications/src/__tests__/badge-storage.test.ts +++ b/services/notifications/src/__tests__/badge-storage.test.ts @@ -17,9 +17,12 @@ function getDO(name: string): DOStub { // helper round-trips rather than exercising dispatchPush again. async function seedBuckets(stub: DOStub, buckets: Record) { await runInDurableObject(stub, async (_inst, state) => { + let total = 0; for (const [bucket, count] of Object.entries(buckets)) { await state.storage.put(`bucket:${bucket}`, count); + total += count; } + await state.storage.put('total', total); }); } @@ -55,6 +58,11 @@ describe('NotificationChannelDO badge storage helpers', () => { return Array.from(entries.entries()); }); expect(remaining).toEqual([['bucket:conv2', 5]]); + + const aggregate = await runInDurableObject(stub, (_inst, state) => + state.storage.get('total') + ); + expect(aggregate).toBe(5); }); it('markBucketRead is idempotent and returns the running total', async () => { diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index a56268ee17..06311f2ed1 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -96,10 +96,11 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(messages[0].badge).toBe(1); // Bucket persisted to DO storage. - const stored = await runInDurableObject(stub, (_inst, state) => - state.storage.get('bucket:conv1') - ); - expect(stored).toBe(1); + const stored = await runInDurableObject(stub, async (_inst, state) => ({ + bucket: await state.storage.get('bucket:conv1'), + total: await state.storage.get('total'), + })); + expect(stored).toEqual({ bucket: 1, total: 1 }); }); it('accumulates bucket counts across deliveries and exposes total via badge', async () => { diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 5fa6916bb9..fda96db3ca 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -17,6 +17,7 @@ type IdemRecord = { stage: 'pending' | 'delivered'; ts: number }; const IDEM_PREFIX = 'idem:'; const BUCKET_PREFIX = 'bucket:'; +const TOTAL_KEY = 'total'; const IDEM_TTL_MS = 60 * 60 * 1000; // 1 hour export class NotificationChannelDO extends DurableObject { @@ -112,8 +113,15 @@ export class NotificationChannelDO extends DurableObject { * marks a conversation as read. */ async markBucketRead(bucket: string): Promise { - await this.ctx.storage.delete(`${BUCKET_PREFIX}${bucket}`); - return this.getTotal(); + const key = `${BUCKET_PREFIX}${bucket}`; + const current = (await this.ctx.storage.get(key)) ?? 0; + const total = await this.getTotal(); + if (current > 0) { + await this.ctx.storage.delete(key); + } + const nextTotal = Math.max(0, total - current); + await this.ctx.storage.put(TOTAL_KEY, nextTotal); + return nextTotal; } /** @@ -164,15 +172,28 @@ export class NotificationChannelDO extends DurableObject { // this is race-free without explicit locking. private async incrementBucket(bucket: string, delta: number): Promise { const key = `${BUCKET_PREFIX}${bucket}`; + const total = await this.getTotal(); const current = (await this.ctx.storage.get(key)) ?? 0; - await this.ctx.storage.put(key, current + delta); + const next = Math.max(0, current + delta); + if (next === 0) { + await this.ctx.storage.delete(key); + } else { + await this.ctx.storage.put(key, next); + } + + await this.ctx.storage.put(TOTAL_KEY, Math.max(0, total + delta)); } - // Sum of all bucket counters for this user. + // Aggregate badge count. Existing DOs without the aggregate fall back to one + // bucket scan and persist the total for subsequent push/read paths. private async getTotal(): Promise { + const stored = await this.ctx.storage.get(TOTAL_KEY); + if (stored !== undefined) return stored; + const entries = await this.ctx.storage.list({ prefix: BUCKET_PREFIX }); let total = 0; for (const value of entries.values()) total += value; + await this.ctx.storage.put(TOTAL_KEY, total); return total; } } From a6373568ab95fd686a7be566dab8cba12d239adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:20:15 +0200 Subject: [PATCH 032/289] perf(kilo-chat): cache sandbox labels --- .../src/__tests__/sandbox-lookup.test.ts | 52 +++++++++++++++++++ .../kilo-chat/src/services/sandbox-lookup.ts | 17 +++++- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 services/kilo-chat/src/__tests__/sandbox-lookup.test.ts diff --git a/services/kilo-chat/src/__tests__/sandbox-lookup.test.ts b/services/kilo-chat/src/__tests__/sandbox-lookup.test.ts new file mode 100644 index 0000000000..b34410b61f --- /dev/null +++ b/services/kilo-chat/src/__tests__/sandbox-lookup.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('../services/sandbox-lookup'); +vi.mock('@kilocode/db/client', () => ({ + getWorkerDb: vi.fn(), +})); + +import { getWorkerDb } from '@kilocode/db/client'; + +import { clearSandboxLabelCache, fetchSandboxLabel } from '../services/sandbox-lookup'; + +type DbState = { + queryCount: number; + labels: Record; +}; + +function installDbMock(state: DbState) { + const fakeDb = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => { + state.queryCount++; + return [{ name: state.labels[state.queryCount.toString()] ?? 'Sandbox' }]; + }, + }), + }), + }), + }; + vi.mocked(getWorkerDb).mockReturnValue(fakeDb as unknown as ReturnType); +} + +describe('fetchSandboxLabel', () => { + beforeEach(() => { + vi.restoreAllMocks(); + clearSandboxLabelCache(); + }); + + it('caches sandbox labels by sandbox id', async () => { + const state: DbState = { + queryCount: 0, + labels: { '1': 'Cached Sandbox', '2': 'Other Sandbox' }, + }; + installDbMock(state); + + await expect(fetchSandboxLabel('postgres://test', 'sandbox-1')).resolves.toBe('Cached Sandbox'); + await expect(fetchSandboxLabel('postgres://test', 'sandbox-1')).resolves.toBe('Cached Sandbox'); + await expect(fetchSandboxLabel('postgres://test', 'sandbox-2')).resolves.toBe('Other Sandbox'); + + expect(state.queryCount).toBe(2); + }); +}); diff --git a/services/kilo-chat/src/services/sandbox-lookup.ts b/services/kilo-chat/src/services/sandbox-lookup.ts index 7dfad7055b..1fbe9d429d 100644 --- a/services/kilo-chat/src/services/sandbox-lookup.ts +++ b/services/kilo-chat/src/services/sandbox-lookup.ts @@ -1,11 +1,24 @@ import { getWorkerDb } from '@kilocode/db/client'; import { kiloclaw_instances } from '@kilocode/db/schema'; import { and, eq, isNull } from 'drizzle-orm'; +import { LRUCache } from 'lru-cache'; + +const sandboxLabelCache = new LRUCache({ + max: 500, + ttl: 5 * 60 * 1000, +}); + +export function clearSandboxLabelCache(): void { + sandboxLabelCache.clear(); +} export async function fetchSandboxLabel( hyperdriveConnectionString: string, sandboxId: string ): Promise { + const cached = sandboxLabelCache.get(sandboxId); + if (cached) return cached; + const db = getWorkerDb(hyperdriveConnectionString); const [row] = await db .select({ name: kiloclaw_instances.name }) @@ -14,5 +27,7 @@ export async function fetchSandboxLabel( and(eq(kiloclaw_instances.sandbox_id, sandboxId), isNull(kiloclaw_instances.destroyed_at)) ) .limit(1); - return row?.name ?? 'KiloClaw'; + const label = row?.name ?? 'KiloClaw'; + sandboxLabelCache.set(sandboxId, label); + return label; } From aa5943281dbdb7d69fed7bb5a4f63342d56f49a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:35:06 +0200 Subject: [PATCH 033/289] fix(db): collapse badge count drop migration --- .../src/migrations/0107_bizarre_sasquatch.sql | 1 + .../src/migrations/0107_dapper_power_pack.sql | 9 - .../src/migrations/0108_drop_badge_counts.sql | 1 - .../db/src/migrations/meta/0107_snapshot.json | 63 +- .../db/src/migrations/meta/0108_snapshot.json | 17955 ---------------- packages/db/src/migrations/meta/_journal.json | 11 +- 6 files changed, 4 insertions(+), 18036 deletions(-) create mode 100644 packages/db/src/migrations/0107_bizarre_sasquatch.sql delete mode 100644 packages/db/src/migrations/0107_dapper_power_pack.sql delete mode 100644 packages/db/src/migrations/0108_drop_badge_counts.sql delete mode 100644 packages/db/src/migrations/meta/0108_snapshot.json diff --git a/packages/db/src/migrations/0107_bizarre_sasquatch.sql b/packages/db/src/migrations/0107_bizarre_sasquatch.sql new file mode 100644 index 0000000000..8fba4c2ac6 --- /dev/null +++ b/packages/db/src/migrations/0107_bizarre_sasquatch.sql @@ -0,0 +1 @@ +DROP TABLE "channel_badge_counts" CASCADE; \ No newline at end of file diff --git a/packages/db/src/migrations/0107_dapper_power_pack.sql b/packages/db/src/migrations/0107_dapper_power_pack.sql deleted file mode 100644 index 0d5d3f3185..0000000000 --- a/packages/db/src/migrations/0107_dapper_power_pack.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE "channel_badge_counts" RENAME TO "badge_counts";--> statement-breakpoint -ALTER TABLE "badge_counts" RENAME COLUMN "channel_id" TO "badge_bucket";--> statement-breakpoint -ALTER TABLE "badge_counts" DROP CONSTRAINT "channel_badge_counts_user_id_kilocode_users_id_fk"; ---> statement-breakpoint -ALTER TABLE "badge_counts" DROP CONSTRAINT "channel_badge_counts_user_id_channel_id_pk";--> statement-breakpoint -ALTER TABLE "badge_counts" ADD CONSTRAINT "badge_counts_user_id_badge_bucket_pk" PRIMARY KEY("user_id","badge_bucket");--> statement-breakpoint -ALTER TABLE "badge_counts" ADD CONSTRAINT "badge_counts_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE no action; ---> statement-breakpoint -DELETE FROM badge_counts; \ No newline at end of file diff --git a/packages/db/src/migrations/0108_drop_badge_counts.sql b/packages/db/src/migrations/0108_drop_badge_counts.sql deleted file mode 100644 index df38dc792a..0000000000 --- a/packages/db/src/migrations/0108_drop_badge_counts.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "badge_counts" CASCADE; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0107_snapshot.json b/packages/db/src/migrations/meta/0107_snapshot.json index c9b3c910b4..cddff43fbc 100644 --- a/packages/db/src/migrations/meta/0107_snapshot.json +++ b/packages/db/src/migrations/meta/0107_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5b345e9c-de70-4377-a842-4747d863b166", + "id": "70c51b5f-a9b9-4573-a6fc-98667b039360", "prevId": "ec49ba08-673e-479f-a8a2-490c71ad9186", "version": "7", "dialect": "postgresql", @@ -2598,67 +2598,6 @@ }, "isRLSEnabled": false }, - "public.badge_counts": { - "name": "badge_counts", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "badge_bucket": { - "name": "badge_bucket", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "badge_count": { - "name": "badge_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "badge_counts_user_id_kilocode_users_id_fk": { - "name": "badge_counts_user_id_kilocode_users_id_fk", - "tableFrom": "badge_counts", - "tableTo": "kilocode_users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "badge_counts_user_id_badge_bucket_pk": { - "name": "badge_counts_user_id_badge_bucket_pk", - "columns": [ - "user_id", - "badge_bucket" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.bot_request_cloud_agent_sessions": { "name": "bot_request_cloud_agent_sessions", "schema": "", diff --git a/packages/db/src/migrations/meta/0108_snapshot.json b/packages/db/src/migrations/meta/0108_snapshot.json deleted file mode 100644 index b24f83823b..0000000000 --- a/packages/db/src/migrations/meta/0108_snapshot.json +++ /dev/null @@ -1,17955 +0,0 @@ -{ - "id": "884639ba-07b1-49ac-a684-fc77d415feba", - "prevId": "5b345e9c-de70-4377-a842-4747d863b166", - "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_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_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_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 - }, - "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": {} - } - }, - "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.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_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 - }, - "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": "'v1'" - } - }, - "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": {} - } - }, - "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": false - }, - "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 - }, - "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_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 - }, - "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 - }, - "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": {} - } - }, - "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_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 - }, - "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'" - }, - "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 - }, - "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_repo_pr_sha": { - "name": "UQ_cloud_agent_code_reviews_repo_pr_sha", - "columns": [ - { - "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, - "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 - }, - "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_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.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_file_hash": { - "name": "IDX_code_indexing_manifest_file_hash", - "columns": [ - { - "expression": "file_hash", - "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.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 - }, - "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": {}, - "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.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.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 - }, - "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": {} - } - }, - "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.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 - }, - "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": {} - }, - "idx_free_model_usage_user_created_at": { - "name": "idx_free_model_usage_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, - "where": "\"free_model_usage\".\"kilo_user_id\" is not null", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "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.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', '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', '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')" - } - }, - "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 - }, - "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', 'cron')" - } - }, - "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_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 - }, - "stripe_subscription_id": { - "name": "stripe_subscription_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "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_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": {} - } - }, - "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_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.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 - }, - "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": { - "name": "UQ_kiloclaw_email_log_user_instance_type", - "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" - } - ], - "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 - } - }, - "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": {} - } - }, - "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": {}, - "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 - }, - "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_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_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_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 - }, - "is_admin": { - "name": "is_admin", - "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 - }, - "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 - } - }, - "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_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": { - "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" - } - }, - "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()" - } - }, - "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": {} - } - }, - "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.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_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 - } - }, - "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": {} - } - }, - "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.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.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_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 - }, - "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": {}, - "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_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 - }, - "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": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "organizations_name_not_empty_check": { - "name": "organizations_name_not_empty_check", - "value": "length(trim(\"organizations\".\"name\")) > 0" - } - }, - "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_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 - }, - "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": {} - }, - "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.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_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 - }, - "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 - }, - "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": {} - } - }, - "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.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" - } - }, - "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": { - "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": { - "uq_security_findings_source": { - "name": "uq_security_findings_source", - "nullsNotDistinct": false, - "columns": [ - "repo_full_name", - "source", - "source_id" - ] - } - }, - "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.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.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_fingerprint_data": { - "name": "idx_fingerprint_data", - "columns": [ - { - "expression": "fingerprint_data", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "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_reasons": { - "name": "idx_reasons", - "columns": [ - { - "expression": "reasons", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "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.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_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 - } - }, - "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 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 7db0045126..6b910ddd8b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -754,15 +754,8 @@ { "idx": 107, "version": "7", - "when": 1777476540389, - "tag": "0107_dapper_power_pack", - "breakpoints": true - }, - { - "idx": 108, - "version": "7", - "when": 1777554058793, - "tag": "0108_drop_badge_counts", + "when": 1777563284725, + "tag": "0107_bizarre_sasquatch", "breakpoints": true } ] From 019c12261ab9c4c625119b032bd3c3f6eb3c60a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:35:31 +0200 Subject: [PATCH 034/289] fix(notifications): remove duplicate service class --- services/notifications/src/index.ts | 7 +++ .../src/lib/notifications-service.ts | 63 ------------------- 2 files changed, 7 insertions(+), 63 deletions(-) delete mode 100644 services/notifications/src/lib/notifications-service.ts diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 0c00956795..90edfc219e 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -139,6 +139,13 @@ export async function sendPushForConversationCore( return { perRecipient }; } +/** + * HTTP and RPC entrypoint for the notifications Worker. + * + * RPC callers authenticate implicitly via the binding topology: only Workers + * explicitly bound to `notifications` with `entrypoint: "NotificationsService"` + * can reach these methods. No shared secret is needed. + */ export class NotificationsService extends WorkerEntrypoint { override async fetch(request: Request): Promise { return app.fetch(request, this.env, this.ctx); diff --git a/services/notifications/src/lib/notifications-service.ts b/services/notifications/src/lib/notifications-service.ts deleted file mode 100644 index a69bf44e02..0000000000 --- a/services/notifications/src/lib/notifications-service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { WorkerEntrypoint } from 'cloudflare:workers'; -import { getWorkerDb } from '@kilocode/db/client'; -import { user_push_tokens } from '@kilocode/db/schema'; -import { eq, inArray } from 'drizzle-orm'; - -import type { - SendInstanceLifecycleNotificationParams, - SendInstanceLifecycleNotificationResult, -} from '@kilocode/notifications'; - -import type { TicketTokenPair } from './expo-push'; -import { sendPushNotifications } from './expo-push'; -import { dispatchInstanceLifecyclePush } from './instance-lifecycle-push'; - -export type { - InstanceLifecycleEvent, - SendInstanceLifecycleNotificationParams, - SendInstanceLifecycleNotificationResult, -} from '@kilocode/notifications'; - -type ReceiptCheckMessage = { - ticketTokenPairs: TicketTokenPair[]; -}; - -/** - * RPC entrypoint for other Workers to send non-chat push notifications. - * - * Callers authenticate implicitly via the binding topology — only Workers - * explicitly bound to `notifications` with `entrypoint: "NotificationsService"` - * can reach these methods. No shared secret is needed. - * - * Push data is parsed by the shared `pushDataSchema` in `@kilocode/notifications`. - */ -export class NotificationsService extends WorkerEntrypoint { - async sendInstanceLifecycleNotification( - params: SendInstanceLifecycleNotificationParams - ): Promise { - const db = getWorkerDb(this.env.HYPERDRIVE.connectionString); - - const result = await dispatchInstanceLifecyclePush(params, { - getTokens: async userId => { - const rows = await db - .select({ token: user_push_tokens.token }) - .from(user_push_tokens) - .where(eq(user_push_tokens.user_id, userId)); - return rows.map(r => r.token); - }, - deleteStaleTokens: async tokens => { - await db.delete(user_push_tokens).where(inArray(user_push_tokens.token, tokens)); - }, - sendPush: async messages => { - const accessToken = await this.env.EXPO_ACCESS_TOKEN.get(); - return sendPushNotifications(messages, accessToken); - }, - enqueueReceipts: async ticketTokenPairs => { - const receiptMsg: ReceiptCheckMessage = { ticketTokenPairs }; - await this.env.RECEIPTS_QUEUE.send(receiptMsg, { delaySeconds: 900 }); - }, - }); - - return result; - } -} From 418a44b3072e8d0c8aa01216751be74360f63f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:36:00 +0200 Subject: [PATCH 035/289] fix(mobile): inline trivial chat read helpers --- .../kilo-chat/hooks/mark-read-actions.ts | 18 ---------- .../kilo-chat/hooks/use-mark-read.test.ts | 25 -------------- .../kilo-chat/hooks/use-mark-read.ts | 34 +++++++------------ .../kilo-chat/message-list-order.test.ts | 26 -------------- .../kilo-chat/message-list-order.ts | 5 --- .../src/components/kilo-chat/message-list.tsx | 4 +-- 6 files changed, 14 insertions(+), 98 deletions(-) delete mode 100644 apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts delete mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts delete mode 100644 apps/mobile/src/components/kilo-chat/message-list-order.test.ts delete mode 100644 apps/mobile/src/components/kilo-chat/message-list-order.ts diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts deleted file mode 100644 index a04ca68d6a..0000000000 --- a/apps/mobile/src/components/kilo-chat/hooks/mark-read-actions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type MarkBadgeReadResponse } from '@kilocode/notifications'; - -type MarkConversationAndBadgeReadInput = { - conversationId: string; - badgeBucket: string; - markConversationRead: (conversationId: string) => Promise; - markBadgeRead: (badgeBucket: string) => Promise; -}; - -export async function markConversationAndBadgeRead({ - conversationId, - badgeBucket, - markConversationRead, - markBadgeRead, -}: MarkConversationAndBadgeReadInput): Promise { - await markConversationRead(conversationId); - return markBadgeRead(badgeBucket); -} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts deleted file mode 100644 index c7f1b6d696..0000000000 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { markConversationAndBadgeRead } from './mark-read-actions'; - -describe('markConversationAndBadgeRead', () => { - it('marks the kilo-chat conversation and notification badge read', async () => { - const calls: string[] = []; - - await markConversationAndBadgeRead({ - conversationId: 'conversation-1', - badgeBucket: 'bucket-1', - markConversationRead: async conversationId => { - calls.push(`conversation:${conversationId}`); - await Promise.resolve(); - }, - markBadgeRead: async badgeBucket => { - calls.push(`badge:${badgeBucket}`); - await Promise.resolve(); - return { badgeCount: 3 }; - }, - }); - - expect(calls).toEqual(['conversation:conversation-1', 'badge:bucket-1']); - }); -}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index 5003486bca..a235f3090b 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -17,7 +17,6 @@ import { NOTIFICATIONS_URL } from '@/lib/config'; import { useCurrentUserId } from './use-current-user-id'; import { useKiloChatTokenGetter } from './use-kilo-chat-token'; -import { markConversationAndBadgeRead } from './mark-read-actions'; type MarkReadContext = { previousBadges?: BadgeCountRow[]; @@ -41,28 +40,21 @@ export function useMarkRead(client: KiloChatClient) { conversationId, badgeBucket, }: MarkReadInput): Promise => { - const result = await markConversationAndBadgeRead({ - conversationId, - badgeBucket, - markConversationRead: markConversationRead.mutateAsync, - markBadgeRead: async bucket => { - const token = await getToken(); - const input = { badgeBucket: bucket } satisfies MarkBadgeReadInput; - const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(input), - }); - if (!response.ok) { - throw new Error(`Failed to mark badge read: ${response.status}`); - } - return markBadgeReadResponseSchema.parse(await response.json()); + await markConversationRead.mutateAsync(conversationId); + const token = await getToken(); + const input = { badgeBucket } satisfies MarkBadgeReadInput; + const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', }, + body: JSON.stringify(input), }); - return result; + if (!response.ok) { + throw new Error(`Failed to mark badge read: ${response.status}`); + } + return markBadgeReadResponseSchema.parse(await response.json()); }, onMutate: async ({ badgeBucket }): Promise => { if (userId === null) { diff --git a/apps/mobile/src/components/kilo-chat/message-list-order.test.ts b/apps/mobile/src/components/kilo-chat/message-list-order.test.ts deleted file mode 100644 index 3537b6d0b4..0000000000 --- a/apps/mobile/src/components/kilo-chat/message-list-order.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type Message } from '@kilocode/kilo-chat'; - -import { getFlashListMessages } from './message-list-order'; - -function message(id: string): Message { - return { - id, - senderId: 'user:1', - content: [{ type: 'text', text: id }], - inReplyToMessageId: null, - updatedAt: null, - clientUpdatedAt: null, - deleted: false, - deliveryFailed: false, - reactions: [], - }; -} - -describe('getFlashListMessages', () => { - it('keeps oldest-to-newest messages in chronological order', () => { - const messages = [message('oldest'), message('middle'), message('newest')]; - - expect(getFlashListMessages(messages).map(m => m.id)).toEqual(['oldest', 'middle', 'newest']); - }); -}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-order.ts b/apps/mobile/src/components/kilo-chat/message-list-order.ts deleted file mode 100644 index 123faf96d3..0000000000 --- a/apps/mobile/src/components/kilo-chat/message-list-order.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type Message } from '@kilocode/kilo-chat'; - -export function getFlashListMessages(messages: Message[]): Message[] { - return messages; -} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index d3d37cdd78..c12f50cb0f 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -5,8 +5,6 @@ import { View } from 'react-native'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; import { Skeleton } from '@/components/ui/skeleton'; -import { getFlashListMessages } from './message-list-order'; - type Props = { messages: Message[]; currentUserId: string | null; @@ -31,7 +29,7 @@ export function MessageList({ // useMessages returns messages oldest-to-newest. // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition // with startRenderingFromBottom, which expects chronological order. - const chronological = getFlashListMessages(messages); + const chronological = messages; return ( Date: Thu, 30 Apr 2026 17:36:40 +0200 Subject: [PATCH 036/289] fix(kilo-chat): share token response builder --- apps/web/src/app/api/kilo-chat/token/route.ts | 15 ++----------- apps/web/src/lib/kilo-chat/token.ts | 18 ++++++++++++++++ apps/web/src/routers/kilo-chat-router.ts | 21 +++++-------------- 3 files changed, 25 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/kilo-chat/token.ts diff --git a/apps/web/src/app/api/kilo-chat/token/route.ts b/apps/web/src/app/api/kilo-chat/token/route.ts index 27ba3f6ce0..451835c9a9 100644 --- a/apps/web/src/app/api/kilo-chat/token/route.ts +++ b/apps/web/src/app/api/kilo-chat/token/route.ts @@ -1,11 +1,8 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { kiloChatTokenResponseSchema } from '@/lib/kilo-chat/token-schema'; -import { generateApiToken } from '@/lib/tokens'; +import { createKiloChatTokenResponse } from '@/lib/kilo-chat/token'; import { getUserFromAuth } from '@/lib/user.server'; -const ONE_HOUR_SECONDS = 60 * 60; - /** * POST /api/kilo-chat/token * @@ -25,13 +22,5 @@ export async function POST() { if (authFailedResponse) return authFailedResponse; if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const token = generateApiToken( - user, - { tokenSource: 'kilo-chat' }, - { expiresIn: ONE_HOUR_SECONDS } - ); - const expiresAt = new Date(Date.now() + ONE_HOUR_SECONDS * 1000).toISOString(); - const response = kiloChatTokenResponseSchema.parse({ token, expiresAt }); - - return NextResponse.json(response); + return NextResponse.json(createKiloChatTokenResponse(user)); } diff --git a/apps/web/src/lib/kilo-chat/token.ts b/apps/web/src/lib/kilo-chat/token.ts new file mode 100644 index 0000000000..92415b2c49 --- /dev/null +++ b/apps/web/src/lib/kilo-chat/token.ts @@ -0,0 +1,18 @@ +import 'server-only'; +import type { User } from '@kilocode/db/schema'; + +import { generateApiToken } from '@/lib/tokens'; + +import { kiloChatTokenResponseSchema, type KiloChatTokenResponse } from './token-schema'; + +const KILO_CHAT_TOKEN_TTL_SECONDS = 60 * 60; + +export function createKiloChatTokenResponse(user: User): KiloChatTokenResponse { + const token = generateApiToken( + user, + { tokenSource: 'kilo-chat' }, + { expiresIn: KILO_CHAT_TOKEN_TTL_SECONDS } + ); + const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_SECONDS * 1000).toISOString(); + return kiloChatTokenResponseSchema.parse({ token, expiresAt }); +} diff --git a/apps/web/src/routers/kilo-chat-router.ts b/apps/web/src/routers/kilo-chat-router.ts index 170c43ffd7..5a9f9e0d0d 100644 --- a/apps/web/src/routers/kilo-chat-router.ts +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -1,21 +1,10 @@ import 'server-only'; -import { - kiloChatTokenResponseSchema, - type KiloChatTokenResponse, -} from '@/lib/kilo-chat/token-schema'; -import { generateApiToken } from '@/lib/tokens'; +import { createKiloChatTokenResponse } from '@/lib/kilo-chat/token'; +import { kiloChatTokenResponseSchema } from '@/lib/kilo-chat/token-schema'; import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; -const KILO_CHAT_TOKEN_TTL_S = 60 * 60; - export const kiloChatRouter = createTRPCRouter({ - getToken: baseProcedure.output(kiloChatTokenResponseSchema).query(({ ctx }) => { - const token = generateApiToken( - ctx.user, - { tokenSource: 'kilo-chat' }, - { expiresIn: KILO_CHAT_TOKEN_TTL_S } - ); - const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_S * 1000).toISOString(); - return { token, expiresAt } satisfies KiloChatTokenResponse; - }), + getToken: baseProcedure + .output(kiloChatTokenResponseSchema) + .query(({ ctx }) => createKiloChatTokenResponse(ctx.user)), }); From da4b9bfa354aaec2ba7d54ba981b1dfcdd6fd766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:38:18 +0200 Subject: [PATCH 037/289] fix(kilo-chat): return user id with chat token --- .../kilo-chat/conversation-screen.tsx | 8 ++-- .../kilo-chat/hooks/use-current-user-id.ts | 45 ++---------------- .../kilo-chat/hooks/use-kilo-chat-token.ts | 24 +++++++--- .../kilo-chat/hooks/use-messages.ts | 2 +- .../kilo-chat/kilo-chat-provider.tsx | 46 ++++++++++++++++--- apps/web/src/lib/kilo-chat/token-schema.ts | 1 + apps/web/src/lib/kilo-chat/token.ts | 2 +- apps/web/src/routers/kilo-chat-router.test.ts | 1 + packages/kilo-chat-hooks/src/use-messages.ts | 16 +++---- 9 files changed, 75 insertions(+), 70 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 02b6e17163..d757d70536 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -33,10 +33,10 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl } }, [messagesQuery]); - const sendMutation = useSendMessage(client, conversationId, currentUserId ?? ''); - const executeAction = useExecuteAction(client, conversationId, currentUserId ?? ''); - const addReaction = useAddReaction(client, conversationId, currentUserId ?? ''); - const removeReaction = useRemoveReaction(client, conversationId, currentUserId ?? ''); + const sendMutation = useSendMessage(client, conversationId, currentUserId); + const executeAction = useExecuteAction(client, conversationId, currentUserId); + const addReaction = useAddReaction(client, conversationId, currentUserId); + const removeReaction = useRemoveReaction(client, conversationId, currentUserId); const handleSend = useCallback( (text: string) => { sendMutation.mutate({ diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts index 6c1e850f4c..8074e54dd4 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts @@ -1,46 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useContext } from 'react'; -import { useKiloChatTokenGetter } from './use-kilo-chat-token'; +import { KiloChatCurrentUserContext } from '../kilo-chat-provider'; -/** - * Decodes the `kiloUserId` claim from the kilo-chat JWT and returns it as the - * current user's ID. Returns `null` while loading or if the token cannot be - * decoded. The token is minted by `generateApiToken`, which writes the user id - * as `kiloUserId` (not the standard JWT `sub` claim). - */ export function useCurrentUserId(): string | null { - const getToken = useKiloChatTokenGetter(); - const [userId, setUserId] = useState(null); - - useEffect(() => { - let cancelled = false; - - async function fetchUserId() { - try { - const token = await getToken(); - if (cancelled) { - return; - } - const parts = token.split('.'); - if (parts.length < 2 || !parts[1]) { - return; - } - const payload = parts[1]; - const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/')); - const parsed = JSON.parse(decoded) as Record; - const kiloUserId = typeof parsed.kiloUserId === 'string' ? parsed.kiloUserId : null; - setUserId(kiloUserId); - } catch { - // Leave userId as null on failure - } - } - - void fetchUserId(); - - return () => { - cancelled = true; - }; - }, [getToken]); - - return userId; + return useContext(KiloChatCurrentUserContext); } diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts index bec3cbaef0..583ff2ac6a 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -4,9 +4,11 @@ import { useCallback } from 'react'; import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; import { trpcClient } from '@/lib/trpc'; +type KiloChatTokenResponse = Awaited>; + type TokenCache = { authToken: string; - token: string; + response: KiloChatTokenResponse; expiresAtMs: number; }; @@ -14,7 +16,7 @@ type TokenCache = { // a different sign-in within the JWT window doesn't return the previous user's // token. The in-flight ref is keyed the same way for the same reason. let cache: TokenCache | null = null; -let inFlight: { authToken: string; promise: Promise } | null = null; +let inFlight: { authToken: string; promise: Promise } | null = null; /** * Returns a stable getter function that fetches a kilo-chat JWT, caching it @@ -27,6 +29,14 @@ let inFlight: { authToken: string; promise: Promise } | null = null; * its next call instead of permanently capturing `undefined`. */ export function useKiloChatTokenGetter(): () => Promise { + const getTokenResponse = useKiloChatTokenResponseGetter(); + return useCallback(async () => { + const response = await getTokenResponse(); + return response.token; + }, [getTokenResponse]); +} + +export function useKiloChatTokenResponseGetter(): () => Promise { return useCallback(async () => { const authToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); if (!authToken) { @@ -34,7 +44,7 @@ export function useKiloChatTokenGetter(): () => Promise { } if (cache && cache.authToken === authToken && cache.expiresAtMs - Date.now() > 60_000) { - return cache.token; + return cache.response; } if (inFlight && inFlight.authToken === authToken) { @@ -54,8 +64,8 @@ export function useKiloChatTokenGetter(): () => Promise { }, []); } -async function fetchAndCacheToken(authToken: string): Promise { - const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); - cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() }; - return token; +async function fetchAndCacheToken(authToken: string): Promise { + const response = await trpcClient.kiloChat.getToken.query(); + cache = { authToken, response, expiresAtMs: new Date(response.expiresAt).getTime() }; + return response; } diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts index 6586b465e6..8ae382eb99 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts @@ -11,7 +11,7 @@ export { useMessages, useMessageCacheUpdater }; export function useSendMessage( client: KiloChatClient, conversationId: string | null, - currentUserId: string + currentUserId: string | null ) { return useSharedSendMessage(client, conversationId, currentUserId, { onError: err => { diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index d3ca76aa5f..9b08023c7f 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { createContext, useEffect, useState } from 'react'; import { EventServiceClient } from '@kilocode/event-service'; import { KiloChatClient } from '@kilocode/kilo-chat'; @@ -6,14 +6,21 @@ import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks'; import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; -import { useKiloChatTokenGetter } from './hooks/use-kilo-chat-token'; +import { + useKiloChatTokenGetter, + useKiloChatTokenResponseGetter, +} from './hooks/use-kilo-chat-token'; type KiloChatProviderProps = { children: React.ReactNode; }; +export const KiloChatCurrentUserContext = createContext(null); + export function KiloChatProvider({ children }: KiloChatProviderProps) { const getToken = useKiloChatTokenGetter(); + const getTokenResponse = useKiloChatTokenResponseGetter(); + const [currentUserId, setCurrentUserId] = useState(null); const [value] = useState(() => { const eventService = new EventServiceClient({ @@ -35,11 +42,36 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { }; }, [value]); + useEffect(() => { + let cancelled = false; + + async function resolveCurrentUserId() { + try { + const response = await getTokenResponse(); + if (!cancelled) { + setCurrentUserId(response.userId); + } + } catch { + if (!cancelled) { + setCurrentUserId(null); + } + } + } + + void resolveCurrentUserId(); + + return () => { + cancelled = true; + }; + }, [getTokenResponse]); + return ( - - {children} - + + + {children} + + ); } diff --git a/apps/web/src/lib/kilo-chat/token-schema.ts b/apps/web/src/lib/kilo-chat/token-schema.ts index 013846e21c..c5dd80bc49 100644 --- a/apps/web/src/lib/kilo-chat/token-schema.ts +++ b/apps/web/src/lib/kilo-chat/token-schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export const kiloChatTokenResponseSchema = z.object({ token: z.string(), expiresAt: z.iso.datetime(), + userId: z.string().min(1), }); export type KiloChatTokenResponse = z.infer; diff --git a/apps/web/src/lib/kilo-chat/token.ts b/apps/web/src/lib/kilo-chat/token.ts index 92415b2c49..f0b9c6e45d 100644 --- a/apps/web/src/lib/kilo-chat/token.ts +++ b/apps/web/src/lib/kilo-chat/token.ts @@ -14,5 +14,5 @@ export function createKiloChatTokenResponse(user: User): KiloChatTokenResponse { { expiresIn: KILO_CHAT_TOKEN_TTL_SECONDS } ); const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_SECONDS * 1000).toISOString(); - return kiloChatTokenResponseSchema.parse({ token, expiresAt }); + return kiloChatTokenResponseSchema.parse({ token, expiresAt, userId: user.id }); } diff --git a/apps/web/src/routers/kilo-chat-router.test.ts b/apps/web/src/routers/kilo-chat-router.test.ts index 9d64775140..9780b8ff49 100644 --- a/apps/web/src/routers/kilo-chat-router.test.ts +++ b/apps/web/src/routers/kilo-chat-router.test.ts @@ -25,6 +25,7 @@ describe('kiloChat router - getToken', () => { algorithms: ['HS256'], }) as jwt.JwtPayload & { kiloUserId: string; tokenSource: string; version: number }; + expect(result.userId).toBe(testUser.id); expect(payload.kiloUserId).toBe(testUser.id); expect(payload.tokenSource).toBe('kilo-chat'); expect(payload.version).toBe(JWT_TOKEN_VERSION); diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 1c8c689061..5cddfb35e3 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -184,14 +184,14 @@ export function applyMessageCreatedEventToPages( export function useSendMessage( client: KiloChatClient, conversationId: string | null, - currentUserId: string, + currentUserId: string | null, options?: MutationErrorOptions ) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (req: SendMessageVariables) => client.sendMessage(req), onMutate: async (variables: SendMessageVariables) => { - if (!conversationId) return; + if (!conversationId || currentUserId === null) return; const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const pendingId = `pending-${variables.clientId}`; @@ -282,14 +282,14 @@ export function useDeleteMessage(client: KiloChatClient, conversationId: string export function useAddReaction( client: KiloChatClient, conversationId: string | null, - currentUserId: string + currentUserId: string | null ) { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }), onMutate: async variables => { - if (!conversationId) return; + if (!conversationId || currentUserId === null) return; const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); @@ -312,14 +312,14 @@ export function useAddReaction( export function useRemoveReaction( client: KiloChatClient, conversationId: string | null, - currentUserId: string + currentUserId: string | null ) { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) => client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }), onMutate: async variables => { - if (!conversationId) return; + if (!conversationId || currentUserId === null) return; const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); @@ -342,7 +342,7 @@ export function useRemoveReaction( export function useExecuteAction( client: KiloChatClient, conversationId: string | null, - currentUserId: string + currentUserId: string | null ) { const queryClient = useQueryClient(); return useMutation({ @@ -356,7 +356,7 @@ export function useExecuteAction( value: ExecApprovalDecision; }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }), onMutate: async variables => { - if (!conversationId) return; + if (!conversationId || currentUserId === null) return; const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); From 8a326103fbdb574f08e4009d3d532f7d46e30ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:38:50 +0200 Subject: [PATCH 038/289] fix(notifications): parse badge buckets centrally --- .../mobile/src/lib/hooks/use-unread-counts.ts | 6 ++-- packages/notifications/src/badge-buckets.ts | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index 9dbfe3668b..4e51ea80fd 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -2,9 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { - badgeBucketForInstance, type BadgeCountRow, listBadgesResponseSchema, + parentBadgeBucketFor, } from '@kilocode/notifications'; import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; @@ -46,9 +46,7 @@ export function useUnreadCounts() { const byBadgeBucket = useMemo(() => { const map = new Map(); for (const row of query.data ?? []) { - const parts = row.badgeBucket.split(':'); - const aggregateBucket = - parts[0] === 'kiloclaw' && parts[1] ? badgeBucketForInstance(parts[1]) : row.badgeBucket; + const aggregateBucket = parentBadgeBucketFor(row.badgeBucket); map.set(aggregateBucket, (map.get(aggregateBucket) ?? 0) + row.badgeCount); } return map; diff --git a/packages/notifications/src/badge-buckets.ts b/packages/notifications/src/badge-buckets.ts index a1717c5618..e5a09c2029 100644 --- a/packages/notifications/src/badge-buckets.ts +++ b/packages/notifications/src/badge-buckets.ts @@ -10,3 +10,39 @@ export const badgeBucketForConversation = (sandboxId: string, conversationId: st export const badgeBucketForInstance = (sandboxId: string): `kiloclaw:${string}` => `kiloclaw:${sandboxId}`; + +export type ParsedBadgeBucket = + | { + kind: 'kiloclaw-instance'; + sandboxId: string; + } + | { + kind: 'kiloclaw-conversation'; + sandboxId: string; + conversationId: string; + } + | { + kind: 'unknown'; + badgeBucket: string; + }; + +export function parseBadgeBucket(badgeBucket: string): ParsedBadgeBucket { + const parts = badgeBucket.split(':'); + if (parts[0] !== 'kiloclaw' || !parts[1]) { + return { kind: 'unknown', badgeBucket }; + } + if (parts.length === 2) { + return { kind: 'kiloclaw-instance', sandboxId: parts[1] }; + } + if (parts.length === 3 && parts[2]) { + return { kind: 'kiloclaw-conversation', sandboxId: parts[1], conversationId: parts[2] }; + } + return { kind: 'unknown', badgeBucket }; +} + +export function parentBadgeBucketFor(badgeBucket: string): string { + const parsed = parseBadgeBucket(badgeBucket); + return parsed.kind === 'kiloclaw-conversation' + ? badgeBucketForInstance(parsed.sandboxId) + : badgeBucket; +} From 5b299529a2a1cd10e5ce3ff00b9dc523a8207b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 17:39:07 +0200 Subject: [PATCH 039/289] fix(mobile): remove stale chat route comments --- .../src/components/kilo-chat/conversation-list-screen.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index 029e6c5e64..e48dcde197 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -78,7 +78,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { useInstancePresence(sandboxId); function handleRowPress(conversationId: string) { - // Route lands in PR 5d (Task 47) router.push(`/(app)/chat/${sandboxId}/${conversationId}` as Href); } @@ -87,7 +86,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { { sandboxId }, { onSuccess: result => { - // Route lands in PR 5d (Task 47) router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as Href); }, } From c54a7ab25897937cf9afd6df4254c619a05eace1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 21:33:58 +0200 Subject: [PATCH 040/289] fix(kiloclaw): remove stream chat image plugin --- services/kiloclaw/Dockerfile | 4 ---- services/kiloclaw/Dockerfile.local | 3 --- 2 files changed, 7 deletions(-) diff --git a/services/kiloclaw/Dockerfile b/services/kiloclaw/Dockerfile index 888a6788b6..3d9aa31a10 100644 --- a/services/kiloclaw/Dockerfile +++ b/services/kiloclaw/Dockerfile @@ -97,10 +97,6 @@ RUN npm install -g clawhub # Install mcporter (MCP server tooling) RUN npm install -g mcporter@0.7.3 -# Install Stream Chat channel plugin for OpenClaw (installed from GitHub, not npm) -ARG STREAM_CHAT_CACHE_BUSTER=1 -RUN npm install -g github:Kilo-Org/openclaw-channel-streamchat#fix/plugin-name-resolution - # Install summarize (web page summarization CLI) RUN npm install -g @steipete/summarize@0.12.0 diff --git a/services/kiloclaw/Dockerfile.local b/services/kiloclaw/Dockerfile.local index 58fa11742b..4ac508db0a 100644 --- a/services/kiloclaw/Dockerfile.local +++ b/services/kiloclaw/Dockerfile.local @@ -52,9 +52,6 @@ RUN npm install -g clawhub # Install mcporter (MCP server tooling) RUN npm install -g mcporter@0.7.3 -# Install Stream Chat channel plugin for OpenClaw (installed from GitHub, not npm) -RUN npm install -g github:Kilo-Org/openclaw-channel-streamchat#fix/plugin-name-resolution - # Install summarize (web page summarization CLI) RUN npm install -g @steipete/summarize@0.12.0 From e81dbeca9f6615a68d6918d9bdb5ffa3677315bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 21:34:59 +0200 Subject: [PATCH 041/289] fix(notifications): route lifecycle pushes by sandbox id --- apps/mobile/src/lib/notification-path.test.ts | 36 +++++++++++++++++++ apps/mobile/src/lib/notification-path.ts | 8 +++++ apps/mobile/src/lib/notifications.ts | 7 ++-- apps/mobile/vitest.config.ts | 6 +++- packages/notifications/src/push-data.ts | 2 +- packages/notifications/src/rpc-schemas.ts | 3 +- .../kiloclaw-instance/lifecycle-push.ts | 6 ++-- .../src/lib/instance-lifecycle-push.ts | 2 +- .../src/lib/notifications-service.test.ts | 24 ++++++++++--- 9 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/lib/notification-path.test.ts create mode 100644 apps/mobile/src/lib/notification-path.ts diff --git a/apps/mobile/src/lib/notification-path.test.ts b/apps/mobile/src/lib/notification-path.test.ts new file mode 100644 index 0000000000..bc8ea7a67c --- /dev/null +++ b/apps/mobile/src/lib/notification-path.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { notificationPathForData } from './notification-path'; + +describe('notificationPathForData', () => { + it('routes chat message notifications to the conversation screen', () => { + expect( + notificationPathForData({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }) + ).toBe('/(app)/chat/sandbox-1/conversation-1'); + }); + + it('routes ready lifecycle notifications with legacy sandbox IDs to the sandbox chat screen', () => { + expect( + notificationPathForData({ + type: 'instance-lifecycle', + event: 'ready', + sandboxId: 'abcDEF123_-', + }) + ).toBe('/(app)/chat/abcDEF123_-'); + }); + + it('routes start_failed lifecycle notifications with ki sandbox IDs to the sandbox chat screen', () => { + expect( + notificationPathForData({ + type: 'instance-lifecycle', + event: 'start_failed', + sandboxId: 'ki_deadbeef', + }) + ).toBe('/(app)/chat/ki_deadbeef'); + }); +}); diff --git a/apps/mobile/src/lib/notification-path.ts b/apps/mobile/src/lib/notification-path.ts new file mode 100644 index 0000000000..6b6e7f406a --- /dev/null +++ b/apps/mobile/src/lib/notification-path.ts @@ -0,0 +1,8 @@ +import { type PushData } from '@kilocode/notifications'; + +export function notificationPathForData(data: PushData): string { + if (data.type === 'chat.message') { + return `/(app)/chat/${data.sandboxId}/${data.conversationId}`; + } + return `/(app)/chat/${data.sandboxId}`; +} diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 0909512791..2fa8215ca2 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -5,6 +5,8 @@ import { Platform } from 'react-native'; import { type PushData, pushDataSchema } from '@kilocode/notifications'; +import { notificationPathForData } from './notification-path'; + function getProjectId(): string { const eas = expoConstants.expoConfig?.extra?.eas as { projectId?: string } | undefined; const projectId = eas?.projectId; @@ -84,10 +86,7 @@ function instanceChatPath(data: PushData | null): string | null { if (!data) { return null; } - if (data.type === 'chat.message') { - return `/(app)/chat/${data.sandboxId}/${data.conversationId}`; - } - return `/(app)/chat/${data.instanceId}`; + return notificationPathForData(data); } export function setupNotificationResponseHandler() { diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 9b0f9a08e3..0eef653330 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -4,6 +4,10 @@ export default defineConfig({ test: { name: 'mobile-onboarding', environment: 'node', - include: ['src/lib/onboarding/**/*.test.ts', 'src/components/**/*.test.ts'], + include: [ + 'src/lib/*.test.ts', + 'src/lib/onboarding/**/*.test.ts', + 'src/components/**/*.test.ts', + ], }, }); diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts index b489bb82d9..9acdef199f 100644 --- a/packages/notifications/src/push-data.ts +++ b/packages/notifications/src/push-data.ts @@ -15,7 +15,7 @@ export const pushDataSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('instance-lifecycle'), event: z.enum(['ready', 'start_failed']), - instanceId: z.string().min(1), + sandboxId: z.string().min(1), }), ]); diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts index 0e1b53b7bd..a2607d74d7 100644 --- a/packages/notifications/src/rpc-schemas.ts +++ b/packages/notifications/src/rpc-schemas.ts @@ -68,8 +68,7 @@ export type InstanceLifecycleEvent = z.infer { expect(m.data).toEqual({ type: 'instance-lifecycle', event: 'ready', - instanceId: 'sandbox-1', + sandboxId: 'ki_deadbeef', }); expect(m.sound).toBe('default'); expect(m.priority).toBe('high'); @@ -205,12 +204,27 @@ describe('dispatchInstanceLifecyclePush', () => { expect(calls.sentMessages).toHaveLength(0); }); - it('carries the chat route id through to the Expo data payload', async () => { + it('rejects an empty sandboxId before doing any IO', async () => { const { deps, calls } = fakeDeps(); - await dispatchInstanceLifecyclePush(baseParams({ instanceId: 'ki_deadbeef' }), deps); + await expect( + dispatchInstanceLifecyclePush({ ...baseParams(), sandboxId: '' }, deps) + ).rejects.toThrow(); + + expect(calls.getTokenQueries).toHaveLength(0); + expect(calls.sentMessages).toHaveLength(0); + }); + + it('carries sandboxId as the only chat route id in the Expo data payload', async () => { + const { deps, calls } = fakeDeps(); + + await dispatchInstanceLifecyclePush(baseParams({ sandboxId: 'ki_deadbeef' }), deps); const sent = calls.sentMessages[0]; - expect((sent[0].data as { instanceId: string }).instanceId).toBe('ki_deadbeef'); + expect(sent[0].data).toEqual({ + type: 'instance-lifecycle', + event: 'ready', + sandboxId: 'ki_deadbeef', + }); }); }); From 1a71a907cd8edf7491b67bd192a6e02dbba25b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 21:36:10 +0200 Subject: [PATCH 042/289] fix(kilo-chat): avoid stale sandbox ownership cache --- .../src/__tests__/sandbox-ownership.test.ts | 58 +++++++++++++++++++ .../src/services/sandbox-ownership.ts | 51 ++-------------- 2 files changed, 62 insertions(+), 47 deletions(-) create mode 100644 services/kilo-chat/src/__tests__/sandbox-ownership.test.ts diff --git a/services/kilo-chat/src/__tests__/sandbox-ownership.test.ts b/services/kilo-chat/src/__tests__/sandbox-ownership.test.ts new file mode 100644 index 0000000000..ef7861034d --- /dev/null +++ b/services/kilo-chat/src/__tests__/sandbox-ownership.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const dbState = vi.hoisted(() => ({ + rows: [] as { sandbox_id: string; user_id: string }[], + queryCount: 0, +})); + +vi.unmock('../services/sandbox-ownership'); + +vi.mock('@kilocode/db', () => ({ + getWorkerDb: () => ({ + select: (selection: Record) => ({ + from: () => ({ + where: () => ({ + limit: async (limit: number) => { + dbState.queryCount += 1; + const rows = + 'sandbox_id' in selection + ? dbState.rows.map(row => ({ sandbox_id: row.sandbox_id })) + : dbState.rows.map(row => ({ user_id: row.user_id })); + return rows.slice(0, limit); + }, + }), + }), + }), + }), +})); + +const env = { + HYPERDRIVE: { connectionString: 'postgres://test' }, +} as Env; + +const { lookupSandboxOwnerUserId, userOwnsSandbox } = await import('../services/sandbox-ownership'); + +describe('sandbox ownership lookups', () => { + beforeEach(() => { + dbState.rows = [{ sandbox_id: 'sandbox-1', user_id: 'user-1' }]; + dbState.queryCount = 0; + }); + + it('does not reuse a positive ownership result after the instance is destroyed', async () => { + await expect(userOwnsSandbox(env, 'user-1', 'sandbox-1')).resolves.toBe(true); + + dbState.rows = []; + + await expect(userOwnsSandbox(env, 'user-1', 'sandbox-1')).resolves.toBe(false); + expect(dbState.queryCount).toBe(2); + }); + + it('does not reuse a positive owner lookup after the instance is destroyed', async () => { + await expect(lookupSandboxOwnerUserId(env, 'sandbox-1')).resolves.toBe('user-1'); + + dbState.rows = []; + + await expect(lookupSandboxOwnerUserId(env, 'sandbox-1')).resolves.toBeNull(); + expect(dbState.queryCount).toBe(2); + }); +}); diff --git a/services/kilo-chat/src/services/sandbox-ownership.ts b/services/kilo-chat/src/services/sandbox-ownership.ts index 526869ace8..fd3dad3dbe 100644 --- a/services/kilo-chat/src/services/sandbox-ownership.ts +++ b/services/kilo-chat/src/services/sandbox-ownership.ts @@ -2,24 +2,6 @@ import { getWorkerDb } from '@kilocode/db'; import { kiloclaw_instances } from '@kilocode/db/schema'; import { and, eq, isNull } from 'drizzle-orm'; -const TTL_MS = 5 * 60 * 1000; - -type CacheEntry = - | { kind: 'owner'; value: string | null; expiresAt: number } - | { kind: 'owns'; value: boolean; expiresAt: number }; - -const cache = new Map(); - -function readFresh(key: string): CacheEntry | undefined { - const entry = cache.get(key); - if (!entry) return undefined; - if (entry.expiresAt <= Date.now()) { - cache.delete(key); - return undefined; - } - return entry; -} - async function queryOwnsSandbox( connectionString: string, userId: string, @@ -57,48 +39,23 @@ async function querySandboxOwner( /** * Returns true if the user owns an active (non-destroyed) instance for the - * given sandbox. Positive results are cached in-memory for 5 minutes; `false` - * is treated as a cache miss so a freshly-provisioned sandbox starts - * returning true as soon as the DB reflects it. + * given sandbox. */ export async function userOwnsSandbox( env: Env, userId: string, sandboxId: string ): Promise { - const key = `owns:${userId}\0${sandboxId}`; - const hit = readFresh(key); - if (hit && hit.kind === 'owns') return hit.value; - - const value = await queryOwnsSandbox(env.HYPERDRIVE.connectionString, userId, sandboxId); - if (value) { - cache.set(key, { kind: 'owns', value, expiresAt: Date.now() + TTL_MS }); - } - return value; + return await queryOwnsSandbox(env.HYPERDRIVE.connectionString, userId, sandboxId); } /** * Returns the user_id of the sandbox owner (active, non-destroyed instance), - * or null if no active instance exists. Resolved owner ids are cached - * in-memory for 5 minutes; `null` is treated as a cache miss so a - * freshly-provisioned sandbox resolves its owner on the very next call. + * or null if no active instance exists. */ export async function lookupSandboxOwnerUserId( env: Env, sandboxId: string ): Promise { - const key = `owner:${sandboxId}`; - const hit = readFresh(key); - if (hit && hit.kind === 'owner') return hit.value; - - const value = await querySandboxOwner(env.HYPERDRIVE.connectionString, sandboxId); - if (value !== null) { - cache.set(key, { kind: 'owner', value, expiresAt: Date.now() + TTL_MS }); - } - return value; -} - -/** Test-only: reset the shared ownership cache. */ -export function clearSandboxOwnershipCacheForTest(): void { - cache.clear(); + return await querySandboxOwner(env.HYPERDRIVE.connectionString, sandboxId); } From 26fe8f7dc7f7f2ba3d3922acca9fc24b083c097f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 21:37:16 +0200 Subject: [PATCH 043/289] chore(kiloclaw): regenerate worker bindings --- services/kiloclaw/worker-configuration.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/kiloclaw/worker-configuration.d.ts b/services/kiloclaw/worker-configuration.d.ts index ba2b545619..55f4f161fd 100644 --- a/services/kiloclaw/worker-configuration.d.ts +++ b/services/kiloclaw/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: bf2dc9695d3fc36b376b6ca04e7fee27) +// Generated by Wrangler by running `wrangler types` (hash: 93a8761fbbb09f1985bcab174fe8474e) // Runtime types generated with workerd@1.20260312.1 2025-05-06 nodejs_compat declare namespace Cloudflare { interface GlobalProps { From 961c3a6a129d164c13fb9bfa340e1449b9c1a55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 22:04:36 +0200 Subject: [PATCH 044/289] fix(kiloclaw): sanitize legacy stream chat config --- .../kiloclaw/controller/src/bootstrap.test.ts | 59 +++++++++++++++++++ services/kiloclaw/controller/src/bootstrap.ts | 46 ++++++++++++++- .../controller/src/config-writer.test.ts | 18 ++++++ .../kiloclaw/controller/src/config-writer.ts | 38 ++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 37d50ace46..a9f3e35c05 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -922,6 +922,65 @@ describe('runOnboardOrDoctor', () => { ); }); + it('removes stale Stream Chat config before running doctor', () => { + const harness = fakeDeps(); + harness.setConfigExists(true); + (harness.deps.readFileSync as ReturnType).mockReturnValue( + JSON.stringify({ + channels: { + streamchat: { enabled: true }, + }, + plugins: { + load: { + paths: [ + '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat', + '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer', + ], + }, + allow: ['openclaw-channel-streamchat', 'telegram'], + entries: { + 'openclaw-channel-streamchat': { enabled: true }, + }, + }, + }) + ); + + const env: Record = { + KILOCODE_API_KEY: 'test-key', + OPENCLAW_GATEWAY_TOKEN: 'test-token', + AUTO_APPROVE_DEVICES: 'true', + }; + + runOnboardOrDoctor(env, harness.deps); + + const doctorCallIndex = ( + harness.deps.execFileSync as ReturnType + ).mock.calls.findIndex(([_cmd, args]) => Array.isArray(args) && args.includes('doctor')); + expect(doctorCallIndex).not.toBe(-1); + const doctorCallOrder = (harness.deps.execFileSync as ReturnType).mock + .invocationCallOrder[doctorCallIndex]; + + const preDoctorWriteIndex = ( + harness.deps.writeFileSync as ReturnType + ).mock.invocationCallOrder.findIndex(order => order < doctorCallOrder); + expect(preDoctorWriteIndex).not.toBe(-1); + + const preDoctorConfig = JSON.parse(harness.writeCalls[preDoctorWriteIndex].data) as { + channels?: Record; + plugins?: { + load?: { paths?: string[] }; + allow?: string[]; + entries?: Record; + }; + }; + expect(preDoctorConfig.channels).not.toHaveProperty('streamchat'); + expect(preDoctorConfig.plugins?.load?.paths).not.toContain( + '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat' + ); + expect(preDoctorConfig.plugins?.allow).not.toContain('openclaw-channel-streamchat'); + expect(preDoctorConfig.plugins?.entries).not.toHaveProperty('openclaw-channel-streamchat'); + }); + it('migrates legacy plaintext kilocode key in auth-profiles.json to a keyRef', () => { // Integration check: runOnboardOrDoctor must drive the auth-profiles // migration. On a legacy doctor boot, a plaintext key in diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index e6b779efd7..a712b92ad8 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -13,7 +13,12 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync as nodeExecFileSync } from 'node:child_process'; -import { generateBaseConfig, writeBaseConfig, writeMcporterConfig } from './config-writer'; +import { + generateBaseConfig, + sanitizeLegacyStreamChatConfig, + writeBaseConfig, + writeMcporterConfig, +} from './config-writer'; import type { ConfigWriterDeps } from './config-writer'; import { atomicWrite } from './atomic-write'; import { migrateKilocodeAuthProfilesToKeyRef } from './auth-profiles-migration'; @@ -664,6 +669,44 @@ function toAuthProfilesMigrationDeps(deps: BootstrapDeps): AuthProfilesMigration }; } +function sanitizeExistingConfigBeforeDoctor(deps: BootstrapDeps): void { + let parsed: unknown; + try { + parsed = JSON.parse(deps.readFileSync(CONFIG_PATH, 'utf8')); + } catch (error) { + console.warn( + `[controller] Skipping pre-doctor config sanitization: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return; + } + + const before = JSON.stringify(parsed); + sanitizeLegacyStreamChatConfig(parsed); + const serialized = JSON.stringify(parsed, null, 2); + if (JSON.stringify(parsed) === before) { + return; + } + + atomicWrite( + CONFIG_PATH, + serialized, + { + writeFileSync: deps.writeFileSync, + renameSync: deps.renameSync, + unlinkSync: deps.unlinkSync, + chmodSync: deps.chmodSync, + }, + { mode: 0o600 } + ); + console.log('Removed legacy Stream Chat config before doctor'); +} + export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDeps): void { const configExists = deps.existsSync(CONFIG_PATH); const cwDeps = toConfigWriterDeps(deps); @@ -683,6 +726,7 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe } } else { console.log('Using existing config, running doctor...'); + sanitizeExistingConfigBeforeDoctor(deps); deps.execFileSync('openclaw', ['doctor', '--fix', '--non-interactive'], { stdio: 'inherit', }); diff --git a/services/kiloclaw/controller/src/config-writer.test.ts b/services/kiloclaw/controller/src/config-writer.test.ts index 3cf8836666..592e268328 100644 --- a/services/kiloclaw/controller/src/config-writer.test.ts +++ b/services/kiloclaw/controller/src/config-writer.test.ts @@ -648,13 +648,31 @@ describe('generateBaseConfig', () => { it('adds KiloClaw customizer to an existing plugin allowlist', () => { const existing = JSON.stringify({ + channels: { + streamchat: { enabled: true }, + }, plugins: { + load: { + paths: [ + '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat', + '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer', + ], + }, allow: ['openclaw-channel-streamchat', 'telegram', 'kilocode', 'browser'], + entries: { + 'openclaw-channel-streamchat': { enabled: true }, + }, }, }); const { deps } = fakeDeps(existing); const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + expect(config.channels.streamchat).toBeUndefined(); + expect(config.plugins.load.paths).not.toContain( + '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat' + ); + expect(config.plugins.entries).not.toHaveProperty('openclaw-channel-streamchat'); + expect(config.plugins.allow).not.toContain('openclaw-channel-streamchat'); expect(config.plugins.allow).toContain('kiloclaw-customizer'); expect(config.plugins.allow).toContain('kiloclaw-morning-briefing'); }); diff --git a/services/kiloclaw/controller/src/config-writer.ts b/services/kiloclaw/controller/src/config-writer.ts index 6c165fded8..b495f72e5a 100644 --- a/services/kiloclaw/controller/src/config-writer.ts +++ b/services/kiloclaw/controller/src/config-writer.ts @@ -78,6 +78,9 @@ const KILOCLAW_CUSTOMIZER_PLUGIN_PATH = '/usr/local/lib/node_modules/@kiloclaw/k const KILOCLAW_MORNING_BRIEFING_PLUGIN_ID = 'kiloclaw-morning-briefing'; const KILOCLAW_MORNING_BRIEFING_PLUGIN_PATH = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-morning-briefing'; +const LEGACY_STREAM_CHAT_PLUGIN_ID = 'openclaw-channel-streamchat'; +const LEGACY_STREAM_CHAT_PLUGIN_PATH = + '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat'; const KILO_EXA_PROVIDER_ID = 'kilo-exa'; type KiloExaSearchMode = 'kilo-proxy' | 'disabled'; @@ -104,6 +107,39 @@ type ConfigObject = Record; type EnvLike = Record; +export function sanitizeLegacyStreamChatConfig(config: ConfigObject): void { + if (config.channels && typeof config.channels === 'object' && !Array.isArray(config.channels)) { + delete config.channels.streamchat; + } + + if (config.plugins && typeof config.plugins === 'object' && !Array.isArray(config.plugins)) { + if ( + config.plugins.load && + typeof config.plugins.load === 'object' && + !Array.isArray(config.plugins.load) && + Array.isArray(config.plugins.load.paths) + ) { + config.plugins.load.paths = config.plugins.load.paths.filter( + (pluginPath: unknown) => pluginPath !== LEGACY_STREAM_CHAT_PLUGIN_PATH + ); + } + + if ( + config.plugins.entries && + typeof config.plugins.entries === 'object' && + !Array.isArray(config.plugins.entries) + ) { + delete config.plugins.entries[LEGACY_STREAM_CHAT_PLUGIN_ID]; + } + + if (Array.isArray(config.plugins.allow)) { + config.plugins.allow = config.plugins.allow.filter( + (pluginId: unknown) => pluginId !== LEGACY_STREAM_CHAT_PLUGIN_ID + ); + } + } +} + const INBOUND_EMAIL_HOOK_ID = 'cloudflare-email-inbound'; function migrateHookMapping(mapping: ConfigObject): ConfigObject { @@ -199,6 +235,8 @@ export function generateBaseConfig( console.log('No existing config file, starting with empty config'); } + sanitizeLegacyStreamChatConfig(config); + config.gateway = config.gateway ?? {}; config.channels = config.channels ?? {}; From fe7104a3c729963e9a1e844317ddeae813b010f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 22:04:57 +0200 Subject: [PATCH 045/289] fix(kiloclaw): allow kilo chat plugin on upgrades --- .../kiloclaw/controller/src/config-writer.test.ts | 3 ++- services/kiloclaw/controller/src/config-writer.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/services/kiloclaw/controller/src/config-writer.test.ts b/services/kiloclaw/controller/src/config-writer.test.ts index 592e268328..9e25126d7f 100644 --- a/services/kiloclaw/controller/src/config-writer.test.ts +++ b/services/kiloclaw/controller/src/config-writer.test.ts @@ -646,7 +646,7 @@ describe('generateBaseConfig', () => { expect(paths.filter(p => p === morningPluginPath)).toHaveLength(1); }); - it('adds KiloClaw customizer to an existing plugin allowlist', () => { + it('updates managed plugins in an existing plugin allowlist', () => { const existing = JSON.stringify({ channels: { streamchat: { enabled: true }, @@ -675,6 +675,7 @@ describe('generateBaseConfig', () => { expect(config.plugins.allow).not.toContain('openclaw-channel-streamchat'); expect(config.plugins.allow).toContain('kiloclaw-customizer'); expect(config.plugins.allow).toContain('kiloclaw-morning-briefing'); + expect(config.plugins.allow).toContain('kilo-chat'); }); it('configures Telegram channel', () => { diff --git a/services/kiloclaw/controller/src/config-writer.ts b/services/kiloclaw/controller/src/config-writer.ts index b495f72e5a..2e612459f1 100644 --- a/services/kiloclaw/controller/src/config-writer.ts +++ b/services/kiloclaw/controller/src/config-writer.ts @@ -81,6 +81,8 @@ const KILOCLAW_MORNING_BRIEFING_PLUGIN_PATH = const LEGACY_STREAM_CHAT_PLUGIN_ID = 'openclaw-channel-streamchat'; const LEGACY_STREAM_CHAT_PLUGIN_PATH = '/usr/local/lib/node_modules/@wunderchat/openclaw-channel-streamchat'; +const KILO_CHAT_PLUGIN_ID = 'kilo-chat'; +const KILO_CHAT_PLUGIN_PATH = '/usr/local/lib/node_modules/@kiloclaw/kilo-chat'; const KILO_EXA_PROVIDER_ID = 'kilo-exa'; type KiloExaSearchMode = 'kilo-proxy' | 'disabled'; @@ -534,14 +536,16 @@ export function generateBaseConfig( config.plugins.load.paths = Array.isArray(config.plugins.load.paths) ? config.plugins.load.paths : []; - const kiloChatPluginPath = '/usr/local/lib/node_modules/@kiloclaw/kilo-chat'; - if (!(config.plugins.load.paths as string[]).includes(kiloChatPluginPath)) { - (config.plugins.load.paths as string[]).push(kiloChatPluginPath); + if (!(config.plugins.load.paths as string[]).includes(KILO_CHAT_PLUGIN_PATH)) { + (config.plugins.load.paths as string[]).push(KILO_CHAT_PLUGIN_PATH); + } + if (Array.isArray(config.plugins.allow) && !config.plugins.allow.includes(KILO_CHAT_PLUGIN_ID)) { + config.plugins.allow.push(KILO_CHAT_PLUGIN_ID); } config.plugins.entries = config.plugins.entries ?? {}; - config.plugins.entries['kilo-chat'] = config.plugins.entries['kilo-chat'] ?? {}; - config.plugins.entries['kilo-chat'].enabled = true; + config.plugins.entries[KILO_CHAT_PLUGIN_ID] = config.plugins.entries[KILO_CHAT_PLUGIN_ID] ?? {}; + config.plugins.entries[KILO_CHAT_PLUGIN_ID].enabled = true; // Webhook hooks configuration for controller-mediated inbound events. // hooks.token stays local to the machine; external Workers authenticate to From fdb6bdd59b8f61aafb06eb89ac94fcd1e119350e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 22:05:27 +0200 Subject: [PATCH 046/289] fix(kiloclaw): hash image plugin sources locally --- .github/workflows/deploy-kiloclaw.yml | 1 + services/kiloclaw/AGENTS.md | 20 +++++++++++--------- services/kiloclaw/scripts/dev-start.sh | 2 +- services/kiloclaw/scripts/push-dev.sh | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-kiloclaw.yml b/.github/workflows/deploy-kiloclaw.yml index 007dcba3f9..6cd9a58548 100644 --- a/.github/workflows/deploy-kiloclaw.yml +++ b/.github/workflows/deploy-kiloclaw.yml @@ -50,6 +50,7 @@ jobs: # - container/ (COPY container/TOOLS.md → /usr/local/share/kiloclaw/) # - plugins/kiloclaw-customizer/ (COPY plugin package for image install) # - plugins/kilo-chat/ (COPY plugin package for image install) + # - plugins/kiloclaw-morning-briefing/ (COPY plugin package for image install) # - openclaw-pairing-list.js, openclaw-device-pairing-list.js (COPY) # - skills/ (COPY skills/ → /root/clawd/skills/) # diff --git a/services/kiloclaw/AGENTS.md b/services/kiloclaw/AGENTS.md index ff0b45bf9f..f3e8bf8106 100644 --- a/services/kiloclaw/AGENTS.md +++ b/services/kiloclaw/AGENTS.md @@ -319,15 +319,17 @@ These files are COPYed by the Dockerfile and hashed by CI (`deploy-kiloclaw.yml` produce the content-hash image tag. If you add or remove a COPY in the Dockerfile, update the `find` command in the workflow's "Compute source content hash" step to match. -| Path | Purpose | -| --------------------------------- | ------------------------------------------------------- | -| `Dockerfile` | Base image, apt packages, npm versions | -| `controller/` | Compiled to `kiloclaw-controller.js` (entrypoint) | -| `container/` | Runtime assets (e.g. `TOOLS.md`) staged outside `/root` | -| `plugins/kiloclaw-customizer/` | KiloClaw customizer plugin package installed in image | -| `openclaw-pairing-list.js` | Helper script used at runtime by controller | -| `openclaw-device-pairing-list.js` | Helper script used at runtime by controller | -| `skills/` | Custom skills copied to `/root/clawd/skills/` | +| Path | Purpose | +| ------------------------------------ | ------------------------------------------------------- | +| `Dockerfile` | Base image, apt packages, npm versions | +| `controller/` | Compiled to `kiloclaw-controller.js` (entrypoint) | +| `container/` | Runtime assets (e.g. `TOOLS.md`) staged outside `/root` | +| `plugins/kiloclaw-customizer/` | KiloClaw customizer plugin package installed in image | +| `plugins/kilo-chat/` | Kilo Chat channel plugin package installed in image | +| `plugins/kiloclaw-morning-briefing/` | Morning briefing plugin package installed in image | +| `openclaw-pairing-list.js` | Helper script used at runtime by controller | +| `openclaw-device-pairing-list.js` | Helper script used at runtime by controller | +| `skills/` | Custom skills copied to `/root/clawd/skills/` | ## Fly Machine Lifecycle diff --git a/services/kiloclaw/scripts/dev-start.sh b/services/kiloclaw/scripts/dev-start.sh index aade245ae6..ae0dd9969c 100755 --- a/services/kiloclaw/scripts/dev-start.sh +++ b/services/kiloclaw/scripts/dev-start.sh @@ -530,7 +530,7 @@ fi compute_image_hash() { (cd "$KILOCLAW_DIR" \ - && find Dockerfile controller/ container/ skills/ \ + && find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \ openclaw-pairing-list.js openclaw-device-pairing-list.js \ -type f 2>/dev/null \ | sort \ diff --git a/services/kiloclaw/scripts/push-dev.sh b/services/kiloclaw/scripts/push-dev.sh index b9d4305f84..7ba323b954 100755 --- a/services/kiloclaw/scripts/push-dev.sh +++ b/services/kiloclaw/scripts/push-dev.sh @@ -134,7 +134,7 @@ fi CONTENT_HASH=$( cd "$KILOCLAW_DIR" \ - && find Dockerfile controller/ container/ skills/ \ + && find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \ openclaw-pairing-list.js openclaw-device-pairing-list.js \ -type f 2>/dev/null \ | sort \ From 748749b62bb7eedfadd14108b8bb622f00a88451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 22:07:12 +0200 Subject: [PATCH 047/289] fix(kiloclaw): drop stream chat env references --- services/kiloclaw/.dev.vars.example | 4 --- services/kiloclaw/AGENTS.md | 40 ++++++++++----------- services/kiloclaw/worker-configuration.d.ts | 6 ++-- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/services/kiloclaw/.dev.vars.example b/services/kiloclaw/.dev.vars.example index 11812eac3a..2da04d7d44 100644 --- a/services/kiloclaw/.dev.vars.example +++ b/services/kiloclaw/.dev.vars.example @@ -142,7 +142,3 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z # In wrangler dev, it connects to the remote Postgres via the Hyperdrive service. # To use a local Postgres instead, add "localConnectionString" to the hyperdrive # config in wrangler.jsonc (see https://developers.cloudflare.com/hyperdrive/configuration/local-development/). - -# StreamChat API Key -STREAM_CHAT_API_KEY=... -STREAM_CHAT_API_SECRET=... diff --git a/services/kiloclaw/AGENTS.md b/services/kiloclaw/AGENTS.md index f3e8bf8106..272901fab6 100644 --- a/services/kiloclaw/AGENTS.md +++ b/services/kiloclaw/AGENTS.md @@ -259,31 +259,27 @@ User config is transported to the machine via environment variables set in the F **Encrypted (stored as `KILOCLAW_ENC_{name}`, decrypted to `{name}` at boot):** -| Env var (after decrypt) | Source | Purpose | -| ---------------------------- | --------------------------------- | ------------------------------ | -| `KILOCODE_API_KEY` | User config (DO) | KiloCode API authentication | -| `OPENCLAW_GATEWAY_TOKEN` | Derived from sandboxId | Per-user gateway auth | -| `TELEGRAM_BOT_TOKEN` | Decrypted channel token | Telegram channel | -| `DISCORD_BOT_TOKEN` | Decrypted channel token | Discord channel | -| `SLACK_BOT_TOKEN` | Decrypted channel token | Slack channel | -| `SLACK_APP_TOKEN` | Decrypted channel token | Slack channel | -| `STREAM_CHAT_BOT_USER_TOKEN` | Auto-provisioned (Stream Chat DO) | Stream Chat bot authentication | -| User encrypted secrets | Decrypted from RSA envelopes | User-provided credentials | +| Env var (after decrypt) | Source | Purpose | +| ------------------------ | ---------------------------- | --------------------------- | +| `KILOCODE_API_KEY` | User config (DO) | KiloCode API authentication | +| `OPENCLAW_GATEWAY_TOKEN` | Derived from sandboxId | Per-user gateway auth | +| `TELEGRAM_BOT_TOKEN` | Decrypted channel token | Telegram channel | +| `DISCORD_BOT_TOKEN` | Decrypted channel token | Discord channel | +| `SLACK_BOT_TOKEN` | Decrypted channel token | Slack channel | +| `SLACK_APP_TOKEN` | Decrypted channel token | Slack channel | +| User encrypted secrets | Decrypted from RSA envelopes | User-provided credentials | **Plaintext (stored as-is in config.env):** -| Env var | Source | Purpose | -| -------------------------------- | --------------------------------- | ------------------------------------ | -| `KILOCODE_DEFAULT_MODEL` | User config (DO) | Default model for agents | -| `KILOCODE_MODELS_JSON` | User config (DO), JSON-serialized | Available model list | -| `KILOCODE_API_BASE_URL` | Worker env | API base URL override | -| `AUTO_APPROVE_DEVICES` | Hardcoded `true` | Skip device pairing | -| `TELEGRAM_DM_POLICY` | Worker env | Telegram DM policy | -| `DISCORD_DM_POLICY` | Worker env | Discord DM policy | -| `OPENCLAW_ALLOWED_ORIGINS` | Worker env | Control UI WebSocket allowed origins | -| `STREAM_CHAT_API_KEY` | Auto-provisioned (Stream Chat DO) | Stream Chat app key | -| `STREAM_CHAT_BOT_USER_ID` | Auto-provisioned (Stream Chat DO) | Per-user bot identifier | -| `STREAM_CHAT_DEFAULT_CHANNEL_ID` | Auto-provisioned (Stream Chat DO) | Default channel ID for the user | +| Env var | Source | Purpose | +| -------------------------- | --------------------------------- | ------------------------------------ | +| `KILOCODE_DEFAULT_MODEL` | User config (DO) | Default model for agents | +| `KILOCODE_MODELS_JSON` | User config (DO), JSON-serialized | Available model list | +| `KILOCODE_API_BASE_URL` | Worker env | API base URL override | +| `AUTO_APPROVE_DEVICES` | Hardcoded `true` | Skip device pairing | +| `TELEGRAM_DM_POLICY` | Worker env | Telegram DM policy | +| `DISCORD_DM_POLICY` | Worker env | Discord DM policy | +| `OPENCLAW_ALLOWED_ORIGINS` | Worker env | Control UI WebSocket allowed origins | ### AI Provider Selection diff --git a/services/kiloclaw/worker-configuration.d.ts b/services/kiloclaw/worker-configuration.d.ts index 55f4f161fd..b7ecff32b1 100644 --- a/services/kiloclaw/worker-configuration.d.ts +++ b/services/kiloclaw/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 93a8761fbbb09f1985bcab174fe8474e) +// Generated by Wrangler by running `wrangler types` (hash: d8b3b62eeab77a3af97c15ad0753d528) // Runtime types generated with workerd@1.20260312.1 2025-05-06 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -34,8 +34,6 @@ declare namespace Cloudflare { FLY_APP_NAME: string; OPENCLAW_ALLOWED_ORIGINS: string; NEXT_PUBLIC_POSTHOG_KEY: string; - STREAM_CHAT_API_KEY: string; - STREAM_CHAT_API_SECRET: string; KILOCHAT_API_TOKEN: string; KILOCHAT_WEBHOOK_SECRET: string; KILOCHAT_BASE_URL: string; @@ -63,7 +61,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types From dfb375e95e465cfa38e8314eb29e4b1256022301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:12:57 +0200 Subject: [PATCH 048/289] fix(kilo-chat): tighten event payload schemas --- packages/kilo-chat/src/events.ts | 80 +++++++------- packages/kilo-chat/src/schemas.ts | 7 +- packages/kilo-chat/test/events.test.ts | 143 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 38 deletions(-) create mode 100644 packages/kilo-chat/test/events.test.ts diff --git a/packages/kilo-chat/src/events.ts b/packages/kilo-chat/src/events.ts index 75e020394f..8f52a063d5 100644 --- a/packages/kilo-chat/src/events.ts +++ b/packages/kilo-chat/src/events.ts @@ -1,97 +1,105 @@ import { z } from 'zod'; -import { contentBlockSchema } from './schemas'; +import { + actionGroupIdSchema, + contentBlockSchema, + execApprovalDecisionSchema, + nonEmptyStringSchema, + nonNegativeIntegerSchema, + sandboxIdSchema, + ulidSchema, +} from './schemas'; // ── Per-event payload schemas ─────────────────────────────────────── export const messageCreatedEventSchema = z.object({ - messageId: z.string(), - senderId: z.string(), + messageId: ulidSchema, + senderId: nonEmptyStringSchema, content: z.array(contentBlockSchema), - inReplyToMessageId: z.string().nullable(), - clientId: z.string().nullable(), + inReplyToMessageId: ulidSchema.nullable(), + clientId: ulidSchema.nullable(), }); export const messageUpdatedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, content: z.array(contentBlockSchema), - clientUpdatedAt: z.number().nullable(), + clientUpdatedAt: nonNegativeIntegerSchema.nullable(), }); export const messageDeletedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, }); export const messageDeliveryFailedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, }); export const typingEventSchema = z.object({ - memberId: z.string(), + memberId: nonEmptyStringSchema, }); export const reactionAddedEventSchema = z.object({ - messageId: z.string(), - memberId: z.string(), + messageId: ulidSchema, + memberId: nonEmptyStringSchema, emoji: z.string(), }); export const reactionRemovedEventSchema = z.object({ - messageId: z.string(), - memberId: z.string(), + messageId: ulidSchema, + memberId: nonEmptyStringSchema, emoji: z.string(), }); export const conversationCreatedEventSchema = z.object({ - conversationId: z.string(), + conversationId: ulidSchema, }); export const conversationRenamedEventSchema = z.object({ - conversationId: z.string(), + conversationId: ulidSchema, title: z.string(), }); export const conversationLeftEventSchema = z.object({ - conversationId: z.string(), + conversationId: ulidSchema, }); export const conversationReadEventSchema = z.object({ - conversationId: z.string(), - memberId: z.string(), - lastReadAt: z.number(), + conversationId: ulidSchema, + memberId: nonEmptyStringSchema, + lastReadAt: nonNegativeIntegerSchema, }); export const conversationActivityEventSchema = z.object({ - conversationId: z.string(), - lastActivityAt: z.number(), + conversationId: ulidSchema, + lastActivityAt: nonNegativeIntegerSchema, }); export const actionExecutedEventSchema = z.object({ - conversationId: z.string(), - messageId: z.string(), - groupId: z.string(), - value: z.string(), - executedBy: z.string(), + conversationId: ulidSchema, + messageId: ulidSchema, + groupId: actionGroupIdSchema, + value: execApprovalDecisionSchema, + executedBy: nonEmptyStringSchema, }); export const actionDeliveryFailedEventSchema = z.object({ - conversationId: z.string(), - messageId: z.string(), - groupId: z.string(), + conversationId: ulidSchema, + messageId: ulidSchema, + groupId: actionGroupIdSchema, }); export const botStatusEventSchema = z.object({ - sandboxId: z.string(), + sandboxId: sandboxIdSchema, online: z.boolean(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const conversationStatusEventSchema = z.object({ - conversationId: z.string(), - contextTokens: z.number(), - contextWindow: z.number(), + conversationId: ulidSchema, + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), + at: nonNegativeIntegerSchema, }); // ── Discriminated union keyed on `event` literal ──────────────────── diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 222bc35d05..872a48cbdc 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -12,13 +12,16 @@ export const ACTION_LABEL_MAX_CHARS = 200; // ── Primitives ────────────────────────────────────────────────────── export const ulidSchema = z.string().ulid(); +export const nonNegativeIntegerSchema = z.number().int().nonnegative(); const SANDBOX_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; export const sandboxIdSchema = z.string().regex(SANDBOX_ID_PATTERN, 'Invalid sandboxId'); +export const nonEmptyStringSchema = z.string().min(1); // Approval decision values produced by openclaw's approval runtime. Kept in // lockstep with `ExecApprovalDecision` from `openclaw/plugin-sdk/approval-runtime`. export const execApprovalDecisionSchema = z.enum(['allow-once', 'allow-always', 'deny']); +export const actionGroupIdSchema = z.string().min(1).max(ACTION_LABEL_MAX_CHARS); // Accepts strings up to `max` chars, trims leading/trailing whitespace, and // rejects values that become empty after trimming. Control characters are @@ -59,7 +62,7 @@ export const actionItemSchema = z.object({ export const actionsBlockSchema = z .object({ type: z.literal('actions'), - groupId: z.string().min(1).max(ACTION_LABEL_MAX_CHARS), + groupId: actionGroupIdSchema, actions: z.array(actionItemSchema).max(10), resolved: z .object({ @@ -189,7 +192,7 @@ export const renameConversationRequestSchema = z.object({ }); export const executeActionRequestSchema = z.object({ - groupId: z.string().min(1).max(ACTION_LABEL_MAX_CHARS), + groupId: actionGroupIdSchema, value: execApprovalDecisionSchema, }); diff --git a/packages/kilo-chat/test/events.test.ts b/packages/kilo-chat/test/events.test.ts new file mode 100644 index 0000000000..327501ad21 --- /dev/null +++ b/packages/kilo-chat/test/events.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { ACTION_LABEL_MAX_CHARS } from '../src/schemas'; +import { getKiloChatEventPayloadSchema } from '../src/events'; + +const validConversationId = '01HXYZ00000ABCDEFGHJKMNPQR'; +const validMessageId = '01HXYZ00000ABCDEFGHJKMNPQS'; +const validReplyMessageId = '01HXYZ00000ABCDEFGHJKMNPQT'; +const validClientId = '01HXYZ00000ABCDEFGHJKMNPQV'; + +describe('kilo chat event payload schemas', () => { + it('rejects malformed or empty event identifiers', () => { + const messageCreatedSchema = getKiloChatEventPayloadSchema('message.created'); + expect( + messageCreatedSchema.safeParse({ + messageId: '', + senderId: 'bot:kiloclaw:sandbox-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: null, + clientId: null, + }).success + ).toBe(false); + expect( + messageCreatedSchema.safeParse({ + messageId: validMessageId, + senderId: 'bot:kiloclaw:sandbox-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: 'not-a-ulid', + clientId: validClientId, + }).success + ).toBe(false); + expect( + messageCreatedSchema.safeParse({ + messageId: validMessageId, + senderId: 'bot:kiloclaw:sandbox-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: validReplyMessageId, + clientId: '', + }).success + ).toBe(false); + + const conversationSchema = getKiloChatEventPayloadSchema('conversation.created'); + expect(conversationSchema.safeParse({ conversationId: 'not-a-ulid' }).success).toBe(false); + }); + + it('keeps actor and member identifiers non-empty without requiring ULIDs', () => { + const typingSchema = getKiloChatEventPayloadSchema('typing'); + expect(typingSchema.safeParse({ memberId: 'bot:kiloclaw:sandbox-1' }).success).toBe(true); + expect(typingSchema.safeParse({ memberId: '' }).success).toBe(false); + + const actionSchema = getKiloChatEventPayloadSchema('action.executed'); + expect( + actionSchema.safeParse({ + conversationId: validConversationId, + messageId: validMessageId, + groupId: 'approval-group', + value: 'allow-once', + executedBy: '', + }).success + ).toBe(false); + }); + + it('rejects invalid action decisions and group IDs', () => { + const actionSchema = getKiloChatEventPayloadSchema('action.executed'); + expect( + actionSchema.safeParse({ + conversationId: validConversationId, + messageId: validMessageId, + groupId: 'approval-group', + value: 'maybe', + executedBy: 'user-1', + }).success + ).toBe(false); + expect( + actionSchema.safeParse({ + conversationId: validConversationId, + messageId: validMessageId, + groupId: '', + value: 'deny', + executedBy: 'user-1', + }).success + ).toBe(false); + expect( + actionSchema.safeParse({ + conversationId: validConversationId, + messageId: validMessageId, + groupId: 'g'.repeat(ACTION_LABEL_MAX_CHARS + 1), + value: 'deny', + executedBy: 'user-1', + }).success + ).toBe(false); + }); + + it('rejects negative and fractional event timestamps', () => { + const readSchema = getKiloChatEventPayloadSchema('conversation.read'); + expect( + readSchema.safeParse({ + conversationId: validConversationId, + memberId: 'bot:kiloclaw:sandbox-1', + lastReadAt: -1, + }).success + ).toBe(false); + expect( + readSchema.safeParse({ + conversationId: validConversationId, + memberId: 'bot:kiloclaw:sandbox-1', + lastReadAt: 1.5, + }).success + ).toBe(false); + + const botStatusSchema = getKiloChatEventPayloadSchema('bot.status'); + expect( + botStatusSchema.safeParse({ + sandboxId: 'sandbox-1', + online: true, + at: 1.5, + }).success + ).toBe(false); + }); + + it('rejects negative and fractional status counters', () => { + const statusSchema = getKiloChatEventPayloadSchema('conversation.status'); + expect( + statusSchema.safeParse({ + conversationId: validConversationId, + contextTokens: -1, + contextWindow: 4096, + model: null, + provider: null, + at: 1000, + }).success + ).toBe(false); + expect( + statusSchema.safeParse({ + conversationId: validConversationId, + contextTokens: 0, + contextWindow: 4096.5, + model: null, + provider: null, + at: 1000, + }).success + ).toBe(false); + }); +}); From e43a0c2d17d9506d1c82871399e5dcf190b1246a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:13:56 +0200 Subject: [PATCH 049/289] fix(kilo-chat): validate status numbers --- packages/kilo-chat/src/schemas.ts | 20 +++--- packages/kilo-chat/test/schemas.test.ts | 93 +++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 872a48cbdc..2ccf44e700 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -242,31 +242,31 @@ export const listMessagesQuerySchema = z.object({ export const botStatusRequestSchema = z.object({ online: z.boolean(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const conversationStatusRequestSchema = z.object({ - contextTokens: z.number(), - contextWindow: z.number(), + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const botStatusRecordSchema = z.object({ online: z.boolean(), - at: z.number(), - updatedAt: z.number(), + at: nonNegativeIntegerSchema, + updatedAt: nonNegativeIntegerSchema, }); export const conversationStatusRecordSchema = z.object({ conversationId: z.string(), - contextTokens: z.number(), - contextWindow: z.number(), + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), - updatedAt: z.number(), + at: nonNegativeIntegerSchema, + updatedAt: nonNegativeIntegerSchema, }); export const getBotStatusResponseSchema = z.object({ diff --git a/packages/kilo-chat/test/schemas.test.ts b/packages/kilo-chat/test/schemas.test.ts index d2c0baf330..123045b18c 100644 --- a/packages/kilo-chat/test/schemas.test.ts +++ b/packages/kilo-chat/test/schemas.test.ts @@ -8,8 +8,14 @@ import { editMessageRequestSchema, CONVERSATION_TITLE_MAX_CHARS, MESSAGE_TEXT_MAX_CHARS, + botStatusRecordSchema, + botStatusRequestSchema, + conversationStatusRecordSchema, + conversationStatusRequestSchema, } from '../src/schemas'; +const validConversationId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + describe('title schemas — trim and reject empty', () => { describe('renameConversationRequestSchema', () => { it('rejects empty string', () => { @@ -96,8 +102,6 @@ describe('title schemas — trim and reject empty', () => { }); describe('text content blocks — trim and reject empty', () => { - const validConvId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; - describe('textBlockSchema', () => { it('rejects empty text', () => { const res = textBlockSchema.safeParse({ type: 'text', text: '' }); @@ -146,7 +150,7 @@ describe('text content blocks — trim and reject empty', () => { describe('createMessageRequestSchema', () => { it('rejects whitespace-only text block', () => { const res = createMessageRequestSchema.safeParse({ - conversationId: validConvId, + conversationId: validConversationId, content: [{ type: 'text', text: ' ' }], }); expect(res.success).toBe(false); @@ -154,7 +158,7 @@ describe('text content blocks — trim and reject empty', () => { it('trims text on create', () => { const res = createMessageRequestSchema.safeParse({ - conversationId: validConvId, + conversationId: validConversationId, content: [{ type: 'text', text: ' hi ' }], }); expect(res.success).toBe(true); @@ -169,7 +173,7 @@ describe('text content blocks — trim and reject empty', () => { describe('editMessageRequestSchema', () => { it('rejects whitespace-only text block on edit', () => { const res = editMessageRequestSchema.safeParse({ - conversationId: validConvId, + conversationId: validConversationId, content: [{ type: 'text', text: ' ' }], timestamp: Date.now(), }); @@ -178,7 +182,7 @@ describe('text content blocks — trim and reject empty', () => { it('trims text on edit', () => { const res = editMessageRequestSchema.safeParse({ - conversationId: validConvId, + conversationId: validConversationId, content: [{ type: 'text', text: ' edited ' }], timestamp: Date.now(), }); @@ -190,3 +194,80 @@ describe('text content blocks — trim and reject empty', () => { }); }); }); + +describe('status schemas', () => { + it('rejects negative and fractional bot status timestamps', () => { + expect(botStatusRequestSchema.safeParse({ online: true, at: -1 }).success).toBe(false); + expect(botStatusRequestSchema.safeParse({ online: true, at: 1.5 }).success).toBe(false); + expect(botStatusRecordSchema.safeParse({ online: true, at: -1, updatedAt: 1000 }).success).toBe( + false + ); + expect( + botStatusRecordSchema.safeParse({ online: true, at: 1000, updatedAt: 1000.5 }).success + ).toBe(false); + }); + + it('rejects negative and fractional conversation status numbers', () => { + expect( + conversationStatusRequestSchema.safeParse({ + contextTokens: -1, + contextWindow: 4096, + model: null, + provider: null, + at: 1000, + }).success + ).toBe(false); + expect( + conversationStatusRequestSchema.safeParse({ + contextTokens: 0, + contextWindow: 4096.5, + model: null, + provider: null, + at: 1000, + }).success + ).toBe(false); + expect( + conversationStatusRequestSchema.safeParse({ + contextTokens: 0, + contextWindow: 4096, + model: null, + provider: null, + at: 1000.5, + }).success + ).toBe(false); + expect( + conversationStatusRecordSchema.safeParse({ + conversationId: validConversationId, + contextTokens: 0, + contextWindow: 4096, + model: null, + provider: null, + at: 1000, + updatedAt: -1, + }).success + ).toBe(false); + }); + + it('accepts zero token and window counts', () => { + expect( + conversationStatusRequestSchema.safeParse({ + contextTokens: 0, + contextWindow: 0, + model: null, + provider: null, + at: 0, + }).success + ).toBe(true); + expect( + conversationStatusRecordSchema.safeParse({ + conversationId: validConversationId, + contextTokens: 0, + contextWindow: 0, + model: null, + provider: null, + at: 0, + updatedAt: 0, + }).success + ).toBe(true); + }); +}); From 67da3edbcc9cf5539534bce9f97801bf99a567b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:14:24 +0200 Subject: [PATCH 050/289] fix(notifications): reject empty chat push IDs --- apps/mobile/src/lib/notification-path.test.ts | 48 +++++++++++++++++++ packages/notifications/src/push-data.ts | 8 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/notification-path.test.ts b/apps/mobile/src/lib/notification-path.test.ts index bc8ea7a67c..aaf26d7385 100644 --- a/apps/mobile/src/lib/notification-path.test.ts +++ b/apps/mobile/src/lib/notification-path.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { pushDataSchema } from '@kilocode/notifications'; import { notificationPathForData } from './notification-path'; @@ -34,3 +35,50 @@ describe('notificationPathForData', () => { ).toBe('/(app)/chat/ki_deadbeef'); }); }); + +describe('pushDataSchema', () => { + it('rejects empty chat notification IDs', () => { + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: '', + conversationId: 'conversation-1', + messageId: 'message-1', + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: '', + messageId: 'message-1', + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: '', + }).success + ).toBe(false); + }); + + it('accepts valid chat and lifecycle notification data', () => { + expect( + pushDataSchema.safeParse({ + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }).success + ).toBe(true); + expect( + pushDataSchema.safeParse({ + type: 'instance-lifecycle', + event: 'ready', + sandboxId: 'sandbox-1', + }).success + ).toBe(true); + }); +}); diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts index 9acdef199f..642f17e989 100644 --- a/packages/notifications/src/push-data.ts +++ b/packages/notifications/src/push-data.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +const nonEmptyStringSchema = z.string().min(1); + /** * Schema for the `data` blob attached to Expo push notifications. * This crosses the OS boundary as untyped JSON, so it MUST be @@ -8,9 +10,9 @@ import { z } from 'zod'; export const pushDataSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('chat.message'), - sandboxId: z.string(), - conversationId: z.string(), - messageId: z.string(), + sandboxId: nonEmptyStringSchema, + conversationId: nonEmptyStringSchema, + messageId: nonEmptyStringSchema, }), z.object({ type: z.literal('instance-lifecycle'), From 4d1d7e816198cd3acb5aba3322babe4f536fc89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:24:25 +0200 Subject: [PATCH 051/289] fix(kilo-chat): split message content schemas --- .../claw/kilo-chat/components/MessageArea.tsx | 22 ++++++++- packages/kilo-chat/src/schemas.ts | 30 ++++++++---- packages/kilo-chat/test/schemas.test.ts | 47 ++++++++++++++++++ .../src/__tests__/conversation-do.test.ts | 49 +++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 2789beeb0e..b9c7f88bf8 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { ulid } from 'ulid'; -import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat'; +import type { + Message, + ContentBlock, + EditMessageRequest, + ExecApprovalDecision, +} from '@kilocode/kilo-chat'; import { useMessages, useSendMessage, @@ -46,6 +51,19 @@ type MessageAreaProps = { conversationId: string; }; +function toEditableContent(content: ContentBlock[]): EditMessageRequest['content'] { + return content.map(block => { + if (block.type === 'actions') { + return { + type: 'actions', + groupId: block.groupId, + actions: block.actions, + }; + } + return block; + }); +} + export function MessageArea({ conversationId }: MessageAreaProps) { const { currentUserId, instanceStatus, assistantName, sandboxId, eventService, kiloChatClient } = useKiloChatContext(); @@ -232,7 +250,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const handleEdit = useCallback( (messageId: string, content: ContentBlock[]) => { editMessage.mutate( - { messageId, conversationId, content, timestamp: Date.now() }, + { messageId, conversationId, content: toEditableContent(content), timestamp: Date.now() }, { onError: err => { if (err instanceof KiloChatApiError && err.status === 409) { diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 2ccf44e700..aaa1e0c3c9 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -59,18 +59,25 @@ export const actionItemSchema = z.object({ value: execApprovalDecisionSchema, }); +export const actionResolutionSchema = z.object({ + value: execApprovalDecisionSchema, + resolvedBy: nonEmptyStringSchema, + resolvedAt: nonNegativeIntegerSchema, +}); + +export const inputActionsBlockSchema = z.object({ + type: z.literal('actions'), + groupId: actionGroupIdSchema, + actions: z.array(actionItemSchema).min(1).max(10), + resolved: z.never().optional(), +}); + export const actionsBlockSchema = z .object({ type: z.literal('actions'), groupId: actionGroupIdSchema, actions: z.array(actionItemSchema).max(10), - resolved: z - .object({ - value: execApprovalDecisionSchema, - resolvedBy: z.string(), - resolvedAt: z.number(), - }) - .optional(), + resolved: actionResolutionSchema.optional(), }) .refine(block => block.resolved !== undefined || block.actions.length >= 1, { message: 'actions must contain at least one item unless the block is resolved', @@ -87,6 +94,11 @@ export const contentBlockSchema = z.discriminatedUnion('type', [ actionsBlockSchema, ]); +export const inputContentBlockSchema = z.discriminatedUnion('type', [ + textBlockSchema, + inputActionsBlockSchema, +]); + // ── Reactions ─────────────────────────────────────────────────────── export const reactionSummarySchema = z.object({ @@ -163,7 +175,7 @@ export const okResponseSchema = z.object({ ok: z.literal(true) }); export const createMessageRequestSchema = z.object({ conversationId: ulidSchema, - content: z.array(contentBlockSchema).min(1).max(20), + content: z.array(inputContentBlockSchema).min(1).max(20), inReplyToMessageId: ulidSchema.optional(), clientId: ulidSchema.optional(), }); @@ -175,7 +187,7 @@ export const createMessageResponseSchema = z.object({ export const editMessageRequestSchema = z.object({ conversationId: ulidSchema, - content: z.array(contentBlockSchema).min(1).max(20), + content: z.array(inputContentBlockSchema).min(1).max(20), timestamp: z.number().int().positive(), }); diff --git a/packages/kilo-chat/test/schemas.test.ts b/packages/kilo-chat/test/schemas.test.ts index 123045b18c..7338db17ec 100644 --- a/packages/kilo-chat/test/schemas.test.ts +++ b/packages/kilo-chat/test/schemas.test.ts @@ -168,6 +168,33 @@ describe('text content blocks — trim and reject empty', () => { if (block.type === 'text') expect(block.text).toBe('hi'); } }); + + it('rejects caller-supplied action resolution metadata', () => { + const res = createMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content: [ + { + type: 'actions', + groupId: 'approval', + actions: [{ label: 'Allow', style: 'primary', value: 'allow-once' }], + resolved: { + value: 'allow-once', + resolvedBy: 'user-alice', + resolvedAt: Date.now(), + }, + }, + ], + }); + expect(res.success).toBe(false); + }); + + it('rejects empty action input blocks', () => { + const res = createMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content: [{ type: 'actions', groupId: 'approval', actions: [] }], + }); + expect(res.success).toBe(false); + }); }); describe('editMessageRequestSchema', () => { @@ -192,6 +219,26 @@ describe('text content blocks — trim and reject empty', () => { if (block.type === 'text') expect(block.text).toBe('edited'); } }); + + it('rejects caller-supplied action resolution metadata', () => { + const res = editMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content: [ + { + type: 'actions', + groupId: 'approval', + actions: [{ label: 'Deny', style: 'danger', value: 'deny' }], + resolved: { + value: 'deny', + resolvedBy: 'user-alice', + resolvedAt: Date.now(), + }, + }, + ], + timestamp: Date.now(), + }); + expect(res.success).toBe(false); + }); }); }); diff --git a/services/kilo-chat/src/__tests__/conversation-do.test.ts b/services/kilo-chat/src/__tests__/conversation-do.test.ts index 981fa7a53b..e5ed8c401b 100644 --- a/services/kilo-chat/src/__tests__/conversation-do.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts @@ -532,6 +532,55 @@ describe('ConversationDO', () => { if (!result.ok) return; expect(result.messageSenderId).toBe('bot-primary'); }); + + it('stores server-owned resolution metadata for clients', async () => { + const stub = getStub('conv-execaction-resolved-content'); + await stub.initialize({ + id: 'conv-execaction-resolved-content', + title: 'Action Chat', + createdBy: 'user-alice', + createdAt: 1000, + members: [ + { id: 'user-alice', kind: 'user' as const }, + { id: 'bot-primary', kind: 'bot' as const }, + ], + }); + const create = await stub.createMessage({ + senderId: 'bot-primary', + content: [ + { + type: 'actions' as const, + groupId: 'g1', + actions: [ + { value: 'allow-once', label: 'Allow', style: 'primary' as const }, + { value: 'deny', label: 'Deny', style: 'danger' as const }, + ], + }, + ], + }); + expect(create.ok).toBe(true); + if (!create.ok) return; + + const beforeExecute = Date.now(); + const result = await stub.executeAction({ + messageId: create.messageId, + memberId: 'user-alice', + groupId: 'g1', + value: 'deny', + }); + expect(result.ok).toBe(true); + + const after = await stub.listMessages({ limit: 10 }); + const message = after.messages.find(m => m.id === create.messageId); + expect(message).toBeDefined(); + if (!message) return; + const actionsBlock = message.content.find(block => block.type === 'actions'); + expect(actionsBlock).toBeDefined(); + if (!actionsBlock || actionsBlock.type !== 'actions') return; + expect(actionsBlock.resolved?.value).toBe('deny'); + expect(actionsBlock.resolved?.resolvedBy).toBe('user-alice'); + expect(actionsBlock.resolved?.resolvedAt).toBeGreaterThanOrEqual(beforeExecute); + }); }); describe('revertActionResolution', () => { From e6bc77903c832a5da59f6e4c970804279906cb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:25:12 +0200 Subject: [PATCH 052/289] fix(kilo-chat): skip textless bot webhooks --- .../src/__tests__/webhook-deliver.test.ts | 22 +++++++++++++++++++ services/kilo-chat/src/webhook/deliver.ts | 2 ++ 2 files changed, 24 insertions(+) diff --git a/services/kilo-chat/src/__tests__/webhook-deliver.test.ts b/services/kilo-chat/src/__tests__/webhook-deliver.test.ts index 3954c13cce..f27f939b15 100644 --- a/services/kilo-chat/src/__tests__/webhook-deliver.test.ts +++ b/services/kilo-chat/src/__tests__/webhook-deliver.test.ts @@ -47,6 +47,28 @@ describe('deliverToBot', () => { expect(notifyDeliveryFailed).not.toHaveBeenCalled(); }); + it('skips textless action-only message.created webhooks without marking failure', async () => { + const deliverChatWebhook = vi.fn().mockResolvedValue(undefined); + const notifyDeliveryFailed = vi.fn(); + const env = makeEnvWithConvStub(deliverChatWebhook, notifyDeliveryFailed); + + await deliverToBot( + env, + makeMsg({ + content: [ + { + type: 'actions', + groupId: 'approval', + actions: [{ label: 'Allow', style: 'primary', value: 'allow-once' }], + }, + ], + }) + ); + + expect(deliverChatWebhook).not.toHaveBeenCalled(); + expect(notifyDeliveryFailed).not.toHaveBeenCalled(); + }); + it('retries up to 2 times then notifies failure', async () => { const deliverChatWebhook = vi.fn().mockRejectedValue(new Error('boom')); const notifyDeliveryFailed = vi.fn().mockResolvedValue(undefined); diff --git a/services/kilo-chat/src/webhook/deliver.ts b/services/kilo-chat/src/webhook/deliver.ts index 8431df327f..4386c034de 100644 --- a/services/kilo-chat/src/webhook/deliver.ts +++ b/services/kilo-chat/src/webhook/deliver.ts @@ -62,6 +62,8 @@ export async function deliverToBot( }); const payload = buildPayload(msg); + if (payload.text.length === 0) return; + // Payload fields are already validated; skip redundant Zod parse. const rpcPayload = { targetBotId: msg.targetBotId, From c4d7b72969596189e23260157f2f3190a0461eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:25:48 +0200 Subject: [PATCH 053/289] fix(kilo-chat): reuse event payload schemas --- packages/kilo-chat/src/events.ts | 8 +++-- packages/kilo-chat/src/schemas.ts | 8 +++-- packages/kilo-chat/test/events.test.ts | 46 +++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/kilo-chat/src/events.ts b/packages/kilo-chat/src/events.ts index 8f52a063d5..6a1d0e4522 100644 --- a/packages/kilo-chat/src/events.ts +++ b/packages/kilo-chat/src/events.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; import { actionGroupIdSchema, + conversationTitleSchema, contentBlockSchema, + emojiSchema, execApprovalDecisionSchema, nonEmptyStringSchema, nonNegativeIntegerSchema, @@ -40,13 +42,13 @@ export const typingEventSchema = z.object({ export const reactionAddedEventSchema = z.object({ messageId: ulidSchema, memberId: nonEmptyStringSchema, - emoji: z.string(), + emoji: emojiSchema, }); export const reactionRemovedEventSchema = z.object({ messageId: ulidSchema, memberId: nonEmptyStringSchema, - emoji: z.string(), + emoji: emojiSchema, }); export const conversationCreatedEventSchema = z.object({ @@ -55,7 +57,7 @@ export const conversationCreatedEventSchema = z.object({ export const conversationRenamedEventSchema = z.object({ conversationId: ulidSchema, - title: z.string(), + title: conversationTitleSchema, }); export const conversationLeftEventSchema = z.object({ diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index aaa1e0c3c9..08479b725c 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -35,6 +35,8 @@ const trimmedNonEmptyString = (max: number) => .transform(s => s.trim()) .refine(s => s.length >= 1, { message: 'must not be empty or whitespace-only' }); +export const conversationTitleSchema = trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS); + // 1-64 bytes UTF-8, no C0 (0x00-0x1F) or C1 (0x7F-0x9F) control chars. export const emojiSchema = z .string() @@ -164,7 +166,7 @@ export const conversationDetailSchema = z.object({ export const createConversationRequestSchema = z.object({ sandboxId: sandboxIdSchema, - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS).optional(), + title: conversationTitleSchema.optional(), }); export const createConversationResponseSchema = z.object({ @@ -200,7 +202,7 @@ export const deleteMessageRequestSchema = z.object({ }); export const renameConversationRequestSchema = z.object({ - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS), + title: conversationTitleSchema, }); export const executeActionRequestSchema = z.object({ @@ -300,7 +302,7 @@ export const actionDeliveryFailedRequestSchema = z.object({ }); export const createBotConversationRequestSchema = z.object({ - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS).optional(), + title: conversationTitleSchema.optional(), additionalMembers: z.array(z.string().min(1)).max(20).optional(), }); diff --git a/packages/kilo-chat/test/events.test.ts b/packages/kilo-chat/test/events.test.ts index 327501ad21..43582506ea 100644 --- a/packages/kilo-chat/test/events.test.ts +++ b/packages/kilo-chat/test/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ACTION_LABEL_MAX_CHARS } from '../src/schemas'; +import { ACTION_LABEL_MAX_CHARS, CONVERSATION_TITLE_MAX_CHARS } from '../src/schemas'; import { getKiloChatEventPayloadSchema } from '../src/events'; const validConversationId = '01HXYZ00000ABCDEFGHJKMNPQR'; @@ -140,4 +140,48 @@ describe('kilo chat event payload schemas', () => { }).success ).toBe(false); }); + + it('rejects invalid reaction emoji values', () => { + const reactionAddedSchema = getKiloChatEventPayloadSchema('reaction.added'); + const reactionRemovedSchema = getKiloChatEventPayloadSchema('reaction.removed'); + + expect( + reactionAddedSchema.safeParse({ + messageId: validMessageId, + memberId: 'user-1', + emoji: '', + }).success + ).toBe(false); + expect( + reactionAddedSchema.safeParse({ + messageId: validMessageId, + memberId: 'user-1', + emoji: 'a'.repeat(65), + }).success + ).toBe(false); + expect( + reactionRemovedSchema.safeParse({ + messageId: validMessageId, + memberId: 'user-1', + emoji: 'ok\u0000', + }).success + ).toBe(false); + }); + + it('rejects blank and overlong renamed conversation titles', () => { + const renamedSchema = getKiloChatEventPayloadSchema('conversation.renamed'); + + expect( + renamedSchema.safeParse({ + conversationId: validConversationId, + title: ' ', + }).success + ).toBe(false); + expect( + renamedSchema.safeParse({ + conversationId: validConversationId, + title: 'a'.repeat(CONVERSATION_TITLE_MAX_CHARS + 1), + }).success + ).toBe(false); + }); }); From fcfe516308387db43446485fe4794834b7ca3896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:36:52 +0200 Subject: [PATCH 054/289] chore(kilo-chat): sync plugin wire schemas --- .../plugins/kilo-chat/src/synced/events.ts | 88 +++++++++++-------- .../plugins/kilo-chat/src/synced/schemas.ts | 70 ++++++++++----- 2 files changed, 95 insertions(+), 63 deletions(-) diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts index 75e020394f..6a1d0e4522 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/synced/events.ts @@ -1,97 +1,107 @@ import { z } from 'zod'; -import { contentBlockSchema } from './schemas'; +import { + actionGroupIdSchema, + conversationTitleSchema, + contentBlockSchema, + emojiSchema, + execApprovalDecisionSchema, + nonEmptyStringSchema, + nonNegativeIntegerSchema, + sandboxIdSchema, + ulidSchema, +} from './schemas'; // ── Per-event payload schemas ─────────────────────────────────────── export const messageCreatedEventSchema = z.object({ - messageId: z.string(), - senderId: z.string(), + messageId: ulidSchema, + senderId: nonEmptyStringSchema, content: z.array(contentBlockSchema), - inReplyToMessageId: z.string().nullable(), - clientId: z.string().nullable(), + inReplyToMessageId: ulidSchema.nullable(), + clientId: ulidSchema.nullable(), }); export const messageUpdatedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, content: z.array(contentBlockSchema), - clientUpdatedAt: z.number().nullable(), + clientUpdatedAt: nonNegativeIntegerSchema.nullable(), }); export const messageDeletedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, }); export const messageDeliveryFailedEventSchema = z.object({ - messageId: z.string(), + messageId: ulidSchema, }); export const typingEventSchema = z.object({ - memberId: z.string(), + memberId: nonEmptyStringSchema, }); export const reactionAddedEventSchema = z.object({ - messageId: z.string(), - memberId: z.string(), - emoji: z.string(), + messageId: ulidSchema, + memberId: nonEmptyStringSchema, + emoji: emojiSchema, }); export const reactionRemovedEventSchema = z.object({ - messageId: z.string(), - memberId: z.string(), - emoji: z.string(), + messageId: ulidSchema, + memberId: nonEmptyStringSchema, + emoji: emojiSchema, }); export const conversationCreatedEventSchema = z.object({ - conversationId: z.string(), + conversationId: ulidSchema, }); export const conversationRenamedEventSchema = z.object({ - conversationId: z.string(), - title: z.string(), + conversationId: ulidSchema, + title: conversationTitleSchema, }); export const conversationLeftEventSchema = z.object({ - conversationId: z.string(), + conversationId: ulidSchema, }); export const conversationReadEventSchema = z.object({ - conversationId: z.string(), - memberId: z.string(), - lastReadAt: z.number(), + conversationId: ulidSchema, + memberId: nonEmptyStringSchema, + lastReadAt: nonNegativeIntegerSchema, }); export const conversationActivityEventSchema = z.object({ - conversationId: z.string(), - lastActivityAt: z.number(), + conversationId: ulidSchema, + lastActivityAt: nonNegativeIntegerSchema, }); export const actionExecutedEventSchema = z.object({ - conversationId: z.string(), - messageId: z.string(), - groupId: z.string(), - value: z.string(), - executedBy: z.string(), + conversationId: ulidSchema, + messageId: ulidSchema, + groupId: actionGroupIdSchema, + value: execApprovalDecisionSchema, + executedBy: nonEmptyStringSchema, }); export const actionDeliveryFailedEventSchema = z.object({ - conversationId: z.string(), - messageId: z.string(), - groupId: z.string(), + conversationId: ulidSchema, + messageId: ulidSchema, + groupId: actionGroupIdSchema, }); export const botStatusEventSchema = z.object({ - sandboxId: z.string(), + sandboxId: sandboxIdSchema, online: z.boolean(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const conversationStatusEventSchema = z.object({ - conversationId: z.string(), - contextTokens: z.number(), - contextWindow: z.number(), + conversationId: ulidSchema, + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), + at: nonNegativeIntegerSchema, }); // ── Discriminated union keyed on `event` literal ──────────────────── diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts index 925fa5ecdc..08479b725c 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts @@ -12,13 +12,16 @@ export const ACTION_LABEL_MAX_CHARS = 200; // ── Primitives ────────────────────────────────────────────────────── export const ulidSchema = z.string().ulid(); +export const nonNegativeIntegerSchema = z.number().int().nonnegative(); const SANDBOX_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; export const sandboxIdSchema = z.string().regex(SANDBOX_ID_PATTERN, 'Invalid sandboxId'); +export const nonEmptyStringSchema = z.string().min(1); // Approval decision values produced by openclaw's approval runtime. Kept in // lockstep with `ExecApprovalDecision` from `openclaw/plugin-sdk/approval-runtime`. export const execApprovalDecisionSchema = z.enum(['allow-once', 'allow-always', 'deny']); +export const actionGroupIdSchema = z.string().min(1).max(ACTION_LABEL_MAX_CHARS); // Accepts strings up to `max` chars, trims leading/trailing whitespace, and // rejects values that become empty after trimming. Control characters are @@ -32,6 +35,8 @@ const trimmedNonEmptyString = (max: number) => .transform(s => s.trim()) .refine(s => s.length >= 1, { message: 'must not be empty or whitespace-only' }); +export const conversationTitleSchema = trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS); + // 1-64 bytes UTF-8, no C0 (0x00-0x1F) or C1 (0x7F-0x9F) control chars. export const emojiSchema = z .string() @@ -56,18 +61,25 @@ export const actionItemSchema = z.object({ value: execApprovalDecisionSchema, }); +export const actionResolutionSchema = z.object({ + value: execApprovalDecisionSchema, + resolvedBy: nonEmptyStringSchema, + resolvedAt: nonNegativeIntegerSchema, +}); + +export const inputActionsBlockSchema = z.object({ + type: z.literal('actions'), + groupId: actionGroupIdSchema, + actions: z.array(actionItemSchema).min(1).max(10), + resolved: z.never().optional(), +}); + export const actionsBlockSchema = z .object({ type: z.literal('actions'), - groupId: z.string().min(1).max(ACTION_LABEL_MAX_CHARS), + groupId: actionGroupIdSchema, actions: z.array(actionItemSchema).max(10), - resolved: z - .object({ - value: execApprovalDecisionSchema, - resolvedBy: z.string(), - resolvedAt: z.number(), - }) - .optional(), + resolved: actionResolutionSchema.optional(), }) .refine(block => block.resolved !== undefined || block.actions.length >= 1, { message: 'actions must contain at least one item unless the block is resolved', @@ -84,6 +96,11 @@ export const contentBlockSchema = z.discriminatedUnion('type', [ actionsBlockSchema, ]); +export const inputContentBlockSchema = z.discriminatedUnion('type', [ + textBlockSchema, + inputActionsBlockSchema, +]); + // ── Reactions ─────────────────────────────────────────────────────── export const reactionSummarySchema = z.object({ @@ -132,6 +149,11 @@ export const conversationListItemSchema = z.object({ joinedAt: z.number(), }); +export const conversationCursorSchema = z.object({ + t: z.number().int().nonnegative(), + c: ulidSchema, +}); + export const conversationDetailSchema = z.object({ id: z.string(), title: z.string().nullable(), @@ -144,7 +166,7 @@ export const conversationDetailSchema = z.object({ export const createConversationRequestSchema = z.object({ sandboxId: sandboxIdSchema, - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS).optional(), + title: conversationTitleSchema.optional(), }); export const createConversationResponseSchema = z.object({ @@ -155,7 +177,7 @@ export const okResponseSchema = z.object({ ok: z.literal(true) }); export const createMessageRequestSchema = z.object({ conversationId: ulidSchema, - content: z.array(contentBlockSchema).min(1).max(20), + content: z.array(inputContentBlockSchema).min(1).max(20), inReplyToMessageId: ulidSchema.optional(), clientId: ulidSchema.optional(), }); @@ -167,7 +189,7 @@ export const createMessageResponseSchema = z.object({ export const editMessageRequestSchema = z.object({ conversationId: ulidSchema, - content: z.array(contentBlockSchema).min(1).max(20), + content: z.array(inputContentBlockSchema).min(1).max(20), timestamp: z.number().int().positive(), }); @@ -180,11 +202,11 @@ export const deleteMessageRequestSchema = z.object({ }); export const renameConversationRequestSchema = z.object({ - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS), + title: conversationTitleSchema, }); export const executeActionRequestSchema = z.object({ - groupId: z.string().min(1).max(ACTION_LABEL_MAX_CHARS), + groupId: actionGroupIdSchema, value: execApprovalDecisionSchema, }); @@ -234,31 +256,31 @@ export const listMessagesQuerySchema = z.object({ export const botStatusRequestSchema = z.object({ online: z.boolean(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const conversationStatusRequestSchema = z.object({ - contextTokens: z.number(), - contextWindow: z.number(), + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), + at: nonNegativeIntegerSchema, }); export const botStatusRecordSchema = z.object({ online: z.boolean(), - at: z.number(), - updatedAt: z.number(), + at: nonNegativeIntegerSchema, + updatedAt: nonNegativeIntegerSchema, }); export const conversationStatusRecordSchema = z.object({ conversationId: z.string(), - contextTokens: z.number(), - contextWindow: z.number(), + contextTokens: nonNegativeIntegerSchema, + contextWindow: nonNegativeIntegerSchema, model: z.string().nullable(), provider: z.string().nullable(), - at: z.number(), - updatedAt: z.number(), + at: nonNegativeIntegerSchema, + updatedAt: nonNegativeIntegerSchema, }); export const getBotStatusResponseSchema = z.object({ @@ -280,7 +302,7 @@ export const actionDeliveryFailedRequestSchema = z.object({ }); export const createBotConversationRequestSchema = z.object({ - title: trimmedNonEmptyString(CONVERSATION_TITLE_MAX_CHARS).optional(), + title: conversationTitleSchema.optional(), additionalMembers: z.array(z.string().min(1)).max(20).optional(), }); From 9523bae183e703028c81b683b5d79a17f5960168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 21:40:29 +0200 Subject: [PATCH 055/289] fix(kiloclaw): avoid resolved approval edits --- .../src/__tests__/bot-messages-routes.test.ts | 33 ++++++++ .../src/__tests__/messages-routes.test.ts | 31 ++++++++ .../plugins/kilo-chat/src/approval.test.ts | 76 +++++++++++++++++++ .../plugins/kilo-chat/src/approval.ts | 18 ++--- 4 files changed, 147 insertions(+), 11 deletions(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index 706a8a80ca..72c834f3c6 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -539,6 +539,39 @@ describe('PATCH /bot/v1/sandboxes/:sandboxId/messages/:messageId', () => { expect(res.status).toBe(400); }); + it('returns 400 when edit content includes caller-supplied action resolution', async () => { + const { sandboxId, conversationId, testEnv } = await setupData('bot-edit-resolved-actions'); + const app = makeBotApp(); + const token = await tokenFor(sandboxId); + + const res = await app.request( + `/bot/v1/sandboxes/${sandboxId}/messages/01ARZ3NDEKTSV4RRFFQ69G5FAV`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + conversationId, + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [], + resolved: { + value: 'deny', + resolvedBy: 'user-1', + resolvedAt: 1, + }, + }, + ], + timestamp: Date.now(), + }), + }, + testEnv + ); + + expect(res.status).toBe(400); + }); + it("returns 403 when editing another bot's message", async () => { const { sandboxId, conversationId, messageId, testEnv } = await setupData('bot-edit-forbidden'); // messageId was created by the user in setupData; the bot is a member but didn't author it diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index a2e0138c1b..3abf38eff5 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -337,6 +337,37 @@ describe('PATCH /v1/messages/:id', () => { expect(editRes.status).toBe(409); }); + it('returns 400 when edit content includes caller-supplied action resolution', async () => { + const { conversationId, userApp } = await createConversation('msg-edit-resolved-actions'); + + const editRes = await userApp.request( + '/v1/messages/01ARZ3NDEKTSV4RRFFQ69G5FAV', + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + conversationId, + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [], + resolved: { + value: 'deny', + resolvedBy: 'user-1', + resolvedAt: 1, + }, + }, + ], + timestamp: Date.now(), + }), + }, + env + ); + + expect(editRes.status).toBe(400); + }); + it('returns 403 when non-sender tries to edit', async () => { const { conversationId, diff --git a/services/kiloclaw/plugins/kilo-chat/src/approval.test.ts b/services/kiloclaw/plugins/kilo-chat/src/approval.test.ts index 3f0c8ec3fd..054ef5862e 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/approval.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/approval.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; import { createKiloChatApprovalCapability } from './approval.js'; +import { editMessageRequestSchema } from './synced/schemas.js'; + +const validConversationId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + +function getNativeRuntime() { + const runtime = createKiloChatApprovalCapability().nativeRuntime; + if (!runtime) throw new Error('Expected native runtime'); + return runtime; +} describe('createKiloChatApprovalCapability', () => { const capability = createKiloChatApprovalCapability(); @@ -127,4 +136,71 @@ describe('createKiloChatApprovalCapability', () => { } as never) ).toBe(false); }); + + it('does not edit Kilo Chat again when the final approval payload is resolved', async () => { + const rt = getNativeRuntime(); + const result = rt.presentation.buildResolvedResult({ + view: { + approvalKind: 'plugin', + approvalId: 'approval-1', + title: 'Deploy change', + description: 'Deploy the proposed change', + metadata: [], + decision: 'deny', + resolvedBy: 'user-1', + }, + } as never); + expect(result.action).toBe('update'); + + const fetchCalls: Array<{ input: string | URL | Request; init: RequestInit | undefined }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + fetchCalls.push({ input, init }); + return new Response(JSON.stringify({ messageId: 'msg-1' })); + }; + const originalFetch = globalThis.fetch; + const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + globalThis.fetch = fetchImpl; + process.env.OPENCLAW_GATEWAY_TOKEN = 'gateway-token'; + try { + await rt.transport.updateEntry({ + entry: { + messageId: 'msg-1', + conversationId: validConversationId, + approvalId: 'approval-1', + }, + payload: result.payload, + } as never); + } finally { + globalThis.fetch = originalFetch; + if (originalGatewayToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; + } + } + + expect(fetchCalls).toHaveLength(0); + }); + + it('builds expired approval edits that match the canonical edit payload schema', () => { + const rt = getNativeRuntime(); + const result = rt.presentation.buildExpiredResult({ + view: { + approvalKind: 'plugin', + approvalId: 'approval-1', + title: 'Deploy change', + description: 'Deploy the proposed change', + metadata: [], + }, + } as never); + expect(result.action).toBe('update'); + + const parsed = editMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content: result.payload, + timestamp: 1, + }); + + expect(parsed.success).toBe(true); + }); }); diff --git a/services/kiloclaw/plugins/kilo-chat/src/approval.ts b/services/kiloclaw/plugins/kilo-chat/src/approval.ts index b55c2c6bb3..037e46de37 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/approval.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/approval.ts @@ -108,17 +108,11 @@ function buildResolvedBlocks(view: ResolvedApprovalView): ContentBlock[] { function buildExpiredBlocks(view: ExpiredApprovalView): ContentBlock[] { const textBlock: ContentBlock = { type: 'text', text: buildMetadataText(view) + '\n\n_Expired_' }; - const actionsBlock: ActionsBlock = { - type: 'actions', - groupId: view.approvalId, - actions: [], - resolved: { - value: 'expired', - resolvedBy: 'system', - resolvedAt: Date.now(), - }, - }; - return [textBlock, actionsBlock]; + return [textBlock]; +} + +function hasResolvedActionsBlock(payload: ContentBlock[]): boolean { + return payload.some(block => block.type === 'actions' && block.resolved !== undefined); } // --------------------------------------------------------------------------- @@ -193,6 +187,8 @@ const nativeRuntime: ChannelApprovalNativeRuntimeAdapter< }, updateEntry: async ({ entry, payload }) => { + if (hasResolvedActionsBlock(payload)) return; + const client = makeClient(); const result = await client.editMessage({ conversationId: entry.conversationId, From afcd7725296da15393e62506ad08f7f8a336c835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 22:42:32 +0200 Subject: [PATCH 056/289] fix(kilo-chat): require explicit recipient reads --- .../kilo-chat/conversation-screen.tsx | 21 +++- .../claw/kilo-chat/components/MessageArea.tsx | 18 ++- .../src/__tests__/messages-routes.test.ts | 109 ++++++++++++++++++ services/kilo-chat/src/services/messages.ts | 39 +++---- 4 files changed, 152 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index d757d70536..8fd6b31c3a 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,7 +1,7 @@ import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; import * as Crypto from 'expo-crypto'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; import { toast } from 'sonner-native'; @@ -13,6 +13,7 @@ import { TypingIndicator } from './typing-indicator'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useAppActiveAndFocused } from './hooks/use-app-active-and-focused'; import { useMarkRead } from './hooks/use-mark-read'; import { useMessageCacheUpdater, useMessages, useSendMessage } from './hooks/use-messages'; import { useCurrentUserId } from './hooks/use-current-user-id'; @@ -26,6 +27,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const messagesQuery = useMessages(client, conversationId); const messages = messagesQuery.data?.messages ?? []; + const latestMessageId = messages[messages.length - 1]?.id ?? null; const hasOlder = messagesQuery.hasNextPage; const fetchOlder = useCallback(() => { if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { @@ -76,15 +78,28 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, []); useMessageCacheUpdater(client, sandboxId, conversationId, undefined, handleActionFailed); + const activeAndFocused = useAppActiveAndFocused(); const markRead = useMarkRead(client); + const lastMarkedRef = useRef(null); + useEffect(() => { + if (!activeAndFocused) { + return; + } + const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; + if (lastMarkedRef.current === marker) { + return; + } + lastMarkedRef.current = marker; + markRead(sandboxId, conversationId); + }, [activeAndFocused, conversationId, latestMessageId, markRead, sandboxId]); + useFocusEffect( useCallback(() => { - markRead(sandboxId, conversationId); setActiveChatLocation({ sandboxId, conversationId }); return () => { setActiveChatLocation(null); }; - }, [sandboxId, conversationId, markRead]) + }, [sandboxId, conversationId]) ); return ( diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index b9c7f88bf8..9ea7e3ff48 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -125,6 +125,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { conversationId ); const messages = data?.messages ?? []; + const latestMessageId = messages[messages.length - 1]?.id ?? null; const conversationDetail = useConversationDetail(kiloChatClient, conversationId); const renameConversation = useRenameConversation(kiloChatClient); @@ -153,13 +154,15 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const markRead = useMarkConversationRead(kiloChatClient); const lastMarkedRef = useRef(null); - // Mark conversation as read when opened. react-query's mutate is stable - // across renders, so including it in deps is safe. + // Mark conversation as read when opened and whenever visible hydration or + // realtime receipt advances the newest message. useEffect(() => { - if (lastMarkedRef.current === conversationId) return; - lastMarkedRef.current = conversationId; + if (!visible) return; + const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; + if (lastMarkedRef.current === marker) return; + lastMarkedRef.current = marker; markRead.mutate(conversationId); - }, [conversationId, markRead.mutate]); + }, [conversationId, latestMessageId, markRead.mutate, visible]); // Register side-effect handlers that don't mutate the message cache // (cache updates are handled by useMessageCacheUpdater). @@ -182,8 +185,11 @@ export function MessageArea({ conversationId }: MessageAreaProps) { useEffect(() => { return eventService.onReconnect(() => { void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'messages', conversationId] }); + if (visible) { + markRead.mutate(conversationId); + } }); - }, [eventService, queryClient, conversationId]); + }, [conversationId, eventService, markRead.mutate, queryClient, visible]); // Auto-scroll whenever content height changes (new messages, streaming // updates, image loads). A ResizeObserver on the inner content fires only diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index 3abf38eff5..3718ad6a4f 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -1,5 +1,6 @@ import { env } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { ulid } from 'ulid'; import type { ConversationDO } from '../do/conversation-do'; import { makeApp } from './helpers'; @@ -61,6 +62,55 @@ async function createConversation(userSuffix: string) { return { conversationId, userId, botId, sandboxId, userApp, botApp }; } +async function createMultiHumanConversation(userSuffix: string) { + const userId = `user-${userSuffix}`; + const recipientId = `recipient-${userSuffix}`; + const sandboxId = `sandbox-${userSuffix}`; + const botId = `bot:kiloclaw:${sandboxId}`; + const conversationId = ulid(); + const joinedAt = Date.now(); + const members: Array<{ id: string; kind: 'user' | 'bot' }> = [ + { id: userId, kind: 'user' }, + { id: recipientId, kind: 'user' }, + { id: botId, kind: 'bot' }, + ]; + + const convStub = getConvStub(conversationId); + const initResult = await convStub.initialize({ + id: conversationId, + title: `Chat ${userSuffix}`, + createdBy: userId, + createdAt: joinedAt, + members, + }); + expect(initResult).toEqual({ ok: true }); + + for (const member of members) { + await env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(member.id)).addConversation({ + conversationId, + title: `Chat ${userSuffix}`, + sandboxId, + joinedAt, + }); + } + + const deliveredEventService = { + fetch: env.EVENT_SERVICE.fetch.bind(env.EVENT_SERVICE), + connect: env.EVENT_SERVICE.connect.bind(env.EVENT_SERVICE), + pushEvent: async () => true, + } satisfies Env['EVENT_SERVICE']; + + return { + conversationId, + userId, + recipientId, + sandboxId, + userApp: makeApp(userId, 'user'), + recipientApp: makeApp(recipientId, 'user'), + deliveredEnv: { ...env, EVENT_SERVICE: deliveredEventService } satisfies Env, + }; +} + const sampleContent = [{ type: 'text', text: 'Hello world' }]; describe('POST /v1/messages', () => { @@ -850,6 +900,65 @@ describe('sender conversation read state after sending', () => { }); }); +describe('recipient conversation read state after message delivery', () => { + it('does not mark delivered recipient sockets as read without an explicit read call', async () => { + const { conversationId, recipientId, sandboxId, userApp, deliveredEnv } = + await createMultiHumanConversation('msg-recipient-hidden'); + + const res = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + deliveredEnv + ); + expect(res.status).toBe(201); + + const recipientMemberStub = env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(recipientId)); + const after = await recipientMemberStub.listConversations({ sandboxId }); + const conversation = after.conversations.find(c => c.conversationId === conversationId); + if (!conversation) { + throw new Error('Expected recipient membership conversation'); + } + expect(conversation.lastActivityAt).not.toBeNull(); + expect(conversation.lastReadAt).toBeNull(); + }); + + it('marks recipients read when the visible client explicitly marks the conversation read', async () => { + const { conversationId, recipientId, sandboxId, userApp, recipientApp, deliveredEnv } = + await createMultiHumanConversation('msg-recipient-visible'); + + const createRes = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + deliveredEnv + ); + expect(createRes.status).toBe(201); + + const markReadRes = await recipientApp.request( + `/v1/conversations/${conversationId}/mark-read`, + { method: 'POST' }, + deliveredEnv + ); + expect(markReadRes.status).toBe(204); + + const recipientMemberStub = env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(recipientId)); + const after = await recipientMemberStub.listConversations({ sandboxId }); + const conversation = after.conversations.find(c => c.conversationId === conversationId); + if (!conversation) { + throw new Error('Expected recipient membership conversation'); + } + expect(conversation.lastActivityAt).not.toBeNull(); + expect(conversation.lastReadAt).not.toBeNull(); + }); +}); + describe('auto-title on first message', () => { it('auto-titles an untitled conversation from first message text', async () => { const userId = 'user-autotitle'; diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 1bca6d9ec4..952a3f676a 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -199,11 +199,11 @@ async function postCommitFanOut( } }; - // ── Block B: Push message.created + typing.stop; returns delivery map ─ - const pushMessageEvents = async (): Promise> => { - if (!sandboxId) return new Map(); + // ── Block B: Push message.created + typing.stop ────────────────────── + const pushMessageEvents = async (): Promise => { + if (!sandboxId) return; - const deliveryMap = await pushEventToHumanMembers( + await pushEventToHumanMembers( env, conversationId, sandboxId, @@ -223,18 +223,11 @@ async function postCommitFanOut( memberId: callerId, }); } - - return deliveryMap; }; // Run webhook delivery, ConversationDO title write, and event push in - // parallel; the MembershipDO fan-out needs the delivery map, so it runs - // after pushMessageEvents resolves. - const [, , deliveryMap] = await Promise.all([ - webhookDelivery(), - conversationDoTitleWrite(), - pushMessageEvents(), - ]); + // parallel; membership updates run after commit side effects settle. + await Promise.all([webhookDelivery(), conversationDoTitleWrite(), pushMessageEvents()]); // ── Block C: Single MembershipDO RPC per member ────────────────────── // Combines autoTitle, lastActivityAt, and lastReadAt into one round-trip. @@ -242,14 +235,12 @@ async function postCommitFanOut( // Per-member semantics: // - title : autoTitle string for every member when auto-titling applied. // - activityAt : always `now`. - // - markRead : true when the member's own WS received the event - // (sender for human-sent messages, or a human recipient with - // an active WS for the delivered message.created event). + // - markRead : true only for the sender. Recipients advance read state + // through the explicit mark-read endpoint when their client is + // visible/focused. const postCommitUpdates = await Promise.allSettled( info.members.map(member => { const isSender = isSenderHuman && member.id === callerId; - const delivered = deliveryMap.get(member.id) === true; - const markRead = isSender || delivered; return withDORetry( () => env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(member.id)), stub => @@ -257,7 +248,7 @@ async function postCommitFanOut( conversationId, ...(autoTitle !== null && { title: autoTitle }), activityAt: now, - markRead, + markRead: isSender, }), 'MembershipDO.applyPostCommit' ); @@ -282,10 +273,8 @@ async function postCommitFanOut( } // Every member — including the sender — gets a `conversation.activity` // event so their sidebar row's `lastActivityAt` advances across tabs. - // Independently, anyone who has "read" this message (the sender, who - // authored it, or a recipient whose WS subscribed to the conversation - // context and delivered `message.created`) gets a targeted - // `conversation.read` with their own `memberId`. + // Independently, the sender gets a targeted `conversation.read`; recipients + // must explicitly mark the conversation read from a visible/focused client. instanceEvents.push( pushInstanceEvent(env, sandboxId, humanMemberIds, 'conversation.activity', { conversationId, @@ -293,9 +282,7 @@ async function postCommitFanOut( }) ); for (const userId of humanMemberIds) { - const isSender = userId === callerId; - const present = deliveryMap.get(userId) === true; - if (!isSender && !present) continue; + if (userId !== callerId) continue; instanceEvents.push( pushInstanceEventToUser(env, sandboxId, userId, 'conversation.read', { conversationId, From 18a606343aceb7900e8beeff299ab381af020b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 22:43:11 +0200 Subject: [PATCH 057/289] fix(notifications): record badges without push tokens --- .../src/__tests__/dispatch-push.test.ts | 18 ++++++++++++++++++ .../src/dos/NotificationChannelDO.ts | 19 ++++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index 06311f2ed1..37db5fdc17 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -83,6 +83,24 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(sendPushNotifications).not.toHaveBeenCalled(); }); + it('records unread badge buckets even when the user has no push tokens', async () => { + installDbMock({ tokens: [] }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + const stub = getDO('user-no-token-badge'); + + const result = await stub.dispatchPush( + baseInput({ userId: 'user-no-token-badge', idempotencyKey: 'k-no-token-badge' }) + ); + + expect(result.kind).toBe('no_tokens'); + expect(sendPushNotifications).not.toHaveBeenCalled(); + await expect(stub.listNonZeroBuckets()).resolves.toEqual([ + { badgeBucket: 'conv1', badgeCount: 1 }, + ]); + await expect(stub.markBucketRead('conv1')).resolves.toBe(0); + await expect(stub.listNonZeroBuckets()).resolves.toEqual([]); + }); + it('delivers, increments bucket in DO storage, writes idempotency key', async () => { installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValueOnce(false); diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index fda96db3ca..c91dbefb12 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -40,15 +40,7 @@ export class NotificationChannelDO extends DurableObject { const db = getWorkerDb(this.env.HYPERDRIVE.connectionString); - // 3. Tokens - const tokens = await db - .select({ token: user_push_tokens.token }) - .from(user_push_tokens) - .where(eq(user_push_tokens.user_id, input.userId)); - - if (tokens.length === 0) return { kind: 'no_tokens' }; - - // 4. Badge math. On a retry the badge was already incremented during + // 3. Badge math. On a retry the badge was already incremented during // the prior attempt; re-applying the delta would double-count. // The total is recomputed in either case (other writers may have // advanced it). @@ -67,6 +59,15 @@ export class NotificationChannelDO extends DurableObject { badgeTotal = await this.getTotal(); } + // 4. Tokens. Missing Expo tokens only means no OS push can be sent; the + // in-app badge state above is still authoritative for client hydration. + const tokens = await db + .select({ token: user_push_tokens.token }) + .from(user_push_tokens) + .where(eq(user_push_tokens.user_id, input.userId)); + + if (tokens.length === 0) return { kind: 'no_tokens' }; + // 5. Send via Expo const messages: ExpoPushMessage[] = tokens.map(({ token }) => ({ to: token, From 094138bae7a0943111d0a3a513b9fe9ed49af6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 22:45:47 +0200 Subject: [PATCH 058/289] fix(kilo-chat): roll back failed action webhooks --- .../src/__tests__/webhook-deliver.test.ts | 94 ++++++++++++++++++- services/kilo-chat/src/do/conversation-do.ts | 13 +-- services/kilo-chat/src/services/messages.ts | 1 + services/kilo-chat/src/webhook/deliver.ts | 74 ++++++++++++++- 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/services/kilo-chat/src/__tests__/webhook-deliver.test.ts b/services/kilo-chat/src/__tests__/webhook-deliver.test.ts index f27f939b15..d0afccd271 100644 --- a/services/kilo-chat/src/__tests__/webhook-deliver.test.ts +++ b/services/kilo-chat/src/__tests__/webhook-deliver.test.ts @@ -1,5 +1,12 @@ +import { env } from 'cloudflare:test'; import { describe, it, expect, vi } from 'vitest'; -import { deliverToBot } from '../webhook/deliver'; +import { ulid } from 'ulid'; +import type { ConversationDO } from '../do/conversation-do'; +import { deliverActionExecutedToBot, deliverToBot } from '../webhook/deliver'; + +function getConvStub(convId: string): DurableObjectStub { + return env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(convId)); +} function makeMsg(overrides?: Partial[1]>) { return { @@ -174,3 +181,88 @@ describe('deliverToBot', () => { expect(notifyDeliveryFailed).toHaveBeenCalledWith('msg-1'); }); }); + +describe('deliverActionExecutedToBot', () => { + it('reverts resolved action content and pushes delivery_failed after permanent RPC failure', async () => { + const sandboxId = 'sandbox-action-delivery'; + const conversationId = ulid(); + const userId = 'user-action-delivery'; + const botId = `bot:kiloclaw:${sandboxId}`; + const stub = getConvStub(conversationId); + await stub.initialize({ + id: conversationId, + title: 'Action delivery', + createdBy: userId, + createdAt: Date.now(), + members: [ + { id: userId, kind: 'user' }, + { id: botId, kind: 'bot' }, + ], + }); + const create = await stub.createMessage({ + senderId: botId, + content: [ + { + type: 'actions', + groupId: 'g1', + actions: [{ value: 'allow-once', label: 'Allow', style: 'primary' }], + }, + ], + }); + if (!create.ok) { + throw new Error('Expected action message creation to succeed'); + } + const execute = await stub.executeAction({ + messageId: create.messageId, + memberId: userId, + groupId: 'g1', + value: 'allow-once', + }); + expect(execute.ok).toBe(true); + + const deliverChatWebhook = vi.fn().mockRejectedValue(new Error('bot down')); + const pushEvent = vi.fn().mockResolvedValue(false); + const failingEnv = { + ...env, + KILOCLAW: { + fetch: env.KILOCLAW.fetch.bind(env.KILOCLAW), + connect: env.KILOCLAW.connect.bind(env.KILOCLAW), + deliverChatWebhook, + } satisfies Env['KILOCLAW'], + EVENT_SERVICE: { + fetch: env.EVENT_SERVICE.fetch.bind(env.EVENT_SERVICE), + connect: env.EVENT_SERVICE.connect.bind(env.EVENT_SERVICE), + pushEvent, + } satisfies Env['EVENT_SERVICE'], + } satisfies Env; + + await deliverActionExecutedToBot(failingEnv, { + type: 'action.executed', + targetBotId: botId, + conversationId, + messageId: create.messageId, + groupId: 'g1', + value: 'allow-once', + executedBy: userId, + executedAt: '2026-05-01T00:00:00.000Z', + }); + + expect(deliverChatWebhook).toHaveBeenCalledTimes(3); + const after = await stub.listMessages({ limit: 10 }); + const message = after.messages.find(m => m.id === create.messageId); + if (!message) { + throw new Error('Expected action message to remain stored'); + } + const actionsBlock = message.content.find(block => block.type === 'actions'); + if (!actionsBlock || actionsBlock.type !== 'actions') { + throw new Error('Expected actions block to remain stored'); + } + expect(actionsBlock.resolved).toBeUndefined(); + expect(pushEvent).toHaveBeenCalledWith( + userId, + `/kiloclaw/${sandboxId}/${conversationId}`, + 'action.delivery_failed', + { conversationId, messageId: create.messageId, groupId: 'g1' } + ); + }); +}); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index d88aeee7c9..ee8c858abf 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -4,12 +4,15 @@ import type { Message, ReactionSummary, ExecApprovalDecision, - actionExecutedWebhookSchema, } from '@kilocode/kilo-chat'; -import type { z } from 'zod'; import { DurableObject } from 'cloudflare:workers'; import { logger } from '../util/logger'; -import { deliverToBot, deliverActionExecutedToBot, type WebhookMessage } from '../webhook/deliver'; +import { + deliverToBot, + deliverActionExecutedToBot, + type ActionExecutedWebhookMessage, + type WebhookMessage, +} from '../webhook/deliver'; /** * Parses stored message content JSON. Content was validated by Zod at write @@ -184,9 +187,7 @@ export class ConversationDO extends DurableObject { this.ctx.waitUntil(this.webhookChain); } - async enqueueActionExecutedWebhook( - msg: z.infer & { targetBotId: string } - ): Promise { + async enqueueActionExecutedWebhook(msg: ActionExecutedWebhookMessage): Promise { this.webhookChain = this.webhookChain .catch(() => {}) .then(() => deliverActionExecutedToBot(this.env, msg)); diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 952a3f676a..733a59d021 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -504,6 +504,7 @@ export async function executeActionFor( value, executedBy: callerId, executedAt: new Date().toISOString(), + convContext, }), 'ConversationDO.enqueueActionExecutedWebhook' ); diff --git a/services/kilo-chat/src/webhook/deliver.ts b/services/kilo-chat/src/webhook/deliver.ts index 4386c034de..f217c54aef 100644 --- a/services/kilo-chat/src/webhook/deliver.ts +++ b/services/kilo-chat/src/webhook/deliver.ts @@ -24,6 +24,13 @@ export type WebhookMessage = { inReplyToSender?: string; }; +type ConversationEventContext = { humanMemberIds: string[]; sandboxId: string | null }; + +export type ActionExecutedWebhookMessage = ActionExecutedWebhookPayload & { + targetBotId: string; + convContext?: ConversationEventContext; +}; + function buildPayload(msg: WebhookMessage): MessageCreatedPayload { // Content was validated at the route handler entry point; trust the shape. const text = msg.content @@ -105,7 +112,7 @@ export async function notifyMessageDeliveryFailed( params: { conversationId: string; messageId: string; - convContext?: { humanMemberIds: string[]; sandboxId: string | null }; + convContext?: ConversationEventContext; } ): Promise { await withDORetry( @@ -127,13 +134,55 @@ export async function notifyMessageDeliveryFailed( } } +/** + * Roll back an optimistically resolved action group and push + * `action.delivery_failed` to human members. Used when the direct + * action.executed RPC cannot be delivered to the bot after retries. + */ +export async function notifyActionDeliveryFailed( + env: Env, + params: { + conversationId: string; + messageId: string; + groupId: string; + convContext?: ConversationEventContext; + } +): Promise { + await withDORetry( + () => env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(params.conversationId)), + async stub => { + await stub.revertActionResolution({ + messageId: params.messageId, + groupId: params.groupId, + }); + }, + 'ConversationDO.revertActionResolution' + ); + + const ctx = params.convContext ?? (await getConversationContext(env, params.conversationId)); + if (ctx?.sandboxId) { + await pushEventToHumanMembers( + env, + params.conversationId, + ctx.sandboxId, + ctx.humanMemberIds, + 'action.delivery_failed', + { + conversationId: params.conversationId, + messageId: params.messageId, + groupId: params.groupId, + } + ); + } +} + /** * Delivers an action.executed webhook to a bot via direct RPC to kiloclaw. * Retries up to 2 times, then logs permanent failure. */ export async function deliverActionExecutedToBot( env: Env, - msg: ActionExecutedWebhookPayload & { targetBotId: string } + msg: ActionExecutedWebhookMessage ): Promise { return withLogTags({ source: 'deliverActionExecutedToBot' }, async () => { logger.setTags({ @@ -143,7 +192,16 @@ export async function deliverActionExecutedToBot( }); // Payload fields are already validated; skip redundant Zod parse. - const rpcPayload = msg satisfies z.infer; + const rpcPayload = { + type: msg.type, + targetBotId: msg.targetBotId, + conversationId: msg.conversationId, + messageId: msg.messageId, + groupId: msg.groupId, + value: msg.value, + executedBy: msg.executedBy, + executedAt: msg.executedAt, + } satisfies z.infer; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { await env.KILOCLAW.deliverChatWebhook(rpcPayload); @@ -159,5 +217,15 @@ export async function deliverActionExecutedToBot( } } logger.error('Action webhook permanently failed'); + try { + await notifyActionDeliveryFailed(env, { + conversationId: msg.conversationId, + messageId: msg.messageId, + groupId: msg.groupId, + convContext: msg.convContext, + }); + } catch (err) { + logger.error('Failed to notify action delivery failure', formatError(err)); + } }); } From 19726df42f7ddb986e625be8e6c0ce752962b3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 22:48:15 +0200 Subject: [PATCH 059/289] fix(mobile): add kilo chat message actions --- .../kilo-chat/conversation-screen.tsx | 154 ++++++++++++++++-- .../kilo-chat/live-message-cache.test.ts | 38 ++++- .../kilo-chat/message-actions.test.ts | 25 +++ .../components/kilo-chat/message-actions.ts | 27 +++ .../components/kilo-chat/message-input.tsx | 23 ++- 5 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-actions.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-actions.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 8fd6b31c3a..458ac58286 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,15 +1,23 @@ -import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; -import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { + useAddReaction, + useDeleteMessage, + useEditMessage, + useExecuteAction, + useRemoveReaction, +} from '@kilocode/kilo-chat-hooks'; +import { type ExecApprovalDecision, formatKiloChatError, type Message } from '@kilocode/kilo-chat'; import * as Crypto from 'expo-crypto'; -import { useCallback, useEffect, useRef } from 'react'; -import { KeyboardAvoidingView, Platform, View } from 'react-native'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import { ConversationHeader } from './conversation-header'; +import { buildMessageActionSheetOptions, FIRST_REACTION_EMOJIS } from './message-actions'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; -import { TypingIndicator } from './typing-indicator'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; @@ -21,13 +29,23 @@ import { setActiveChatLocation } from '@/lib/notifications'; type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; +function editableText(message: Message): string { + return message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n'); +} + export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { const client = useKiloChatClient(); const currentUserId = useCurrentUserId(); + const { showActionSheetWithOptions } = useActionSheet(); + const { bottom } = useSafeAreaInsets(); + const [editingMessage, setEditingMessage] = useState(null); const messagesQuery = useMessages(client, conversationId); const messages = messagesQuery.data?.messages ?? []; - const latestMessageId = messages[messages.length - 1]?.id ?? null; + const latestMessageId = messages.at(-1)?.id ?? null; const hasOlder = messagesQuery.hasNextPage; const fetchOlder = useCallback(() => { if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { @@ -36,18 +54,43 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [messagesQuery]); const sendMutation = useSendMessage(client, conversationId, currentUserId); + const editMessage = useEditMessage(client, conversationId); + const deleteMessage = useDeleteMessage(client, conversationId); const executeAction = useExecuteAction(client, conversationId, currentUserId); const addReaction = useAddReaction(client, conversationId, currentUserId); const removeReaction = useRemoveReaction(client, conversationId, currentUserId); + const editingText = useMemo( + () => (editingMessage ? editableText(editingMessage) : ''), + [editingMessage] + ); const handleSend = useCallback( (text: string) => { + if (editingMessage) { + editMessage.mutate( + { + messageId: editingMessage.id, + conversationId, + content: [{ type: 'text', text }], + timestamp: Date.now(), + }, + { + onSuccess: () => { + setEditingMessage(null); + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to edit message')); + }, + } + ); + return; + } sendMutation.mutate({ conversationId, content: [{ type: 'text', text }], clientId: Crypto.randomUUID(), }); }, - [sendMutation, conversationId] + [conversationId, editMessage, editingMessage, sendMutation] ); const handleReactionPress = useCallback( (message: Message, emoji: string) => { @@ -57,9 +100,23 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const hasReacted = message.reactions.find(r => r.emoji === emoji)?.memberIds.includes(currentUserId) ?? false; if (hasReacted) { - removeReaction.mutate({ messageId: message.id, emoji }); + removeReaction.mutate( + { messageId: message.id, emoji }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to remove reaction')); + }, + } + ); } else { - addReaction.mutate({ messageId: message.id, emoji }); + addReaction.mutate( + { messageId: message.id, emoji }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to add reaction')); + }, + } + ); } }, [addReaction, currentUserId, removeReaction] @@ -70,6 +127,69 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [executeAction] ); + const handleLongPressMessage = useCallback( + (message: Message) => { + if (message.deleted) { + return; + } + const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; + const actionSheet = buildMessageActionSheetOptions({ + isOwnMessage, + canReact: currentUserId !== null, + }); + showActionSheetWithOptions( + { + options: actionSheet.options, + cancelButtonIndex: actionSheet.cancelButtonIndex, + destructiveButtonIndex: actionSheet.destructiveButtonIndex, + title: 'Message actions', + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === undefined || index === actionSheet.cancelButtonIndex) { + return; + } + const reactionEmoji = FIRST_REACTION_EMOJIS[index]; + if (reactionEmoji) { + addReaction.mutate( + { messageId: message.id, emoji: reactionEmoji }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to add reaction')); + }, + } + ); + return; + } + const selected = actionSheet.options[index]; + if (selected === 'Edit') { + setEditingMessage(message); + return; + } + if (selected === 'Delete') { + Alert.alert('Delete message?', 'This will remove the message from the conversation.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteMessage.mutate( + { messageId: message.id, conversationId }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to delete message')); + }, + } + ); + }, + }, + ]); + } + } + ); + }, + [addReaction, bottom, conversationId, currentUserId, deleteMessage, showActionSheetWithOptions] + ); useConversationPresence(sandboxId, conversationId); useConversationEventSubscription(sandboxId, conversationId); @@ -116,10 +236,22 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl hasOlder={hasOlder} isExecutingAction={executeAction.isPending} onExecuteAction={handleExecuteAction} + onLongPressMessage={handleLongPressMessage} onReactionPress={handleReactionPress} /> - - + { + setEditingMessage(null); + } + : undefined + } + /> ); diff --git a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts index 263e5e32fe..52234ab028 100644 --- a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts +++ b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { type InfiniteData } from '@tanstack/react-query'; +import { type InfiniteData, QueryClient } from '@tanstack/react-query'; import { type Message, type MessageCreatedEvent } from '@kilocode/kilo-chat'; -import { applyMessageCreatedEventToPages, updateMessageInPages } from '@kilocode/kilo-chat-hooks'; +import { + applyMessageCreatedEventToPages, + applyReactionAdded, + messagesKey, + restoreMessageInCache, + updateMessageInPages, +} from '@kilocode/kilo-chat-hooks'; function message(id: string): Message { return { @@ -67,3 +73,31 @@ describe('updateMessageInPages', () => { expect(result.pages[1]?.[0]?.deleted).toBe(true); }); }); + +describe('shared optimistic rollback helpers', () => { + it('restores snapshotted message content for edit and delete rollbacks', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conv-rollback'); + const original = message('m1'); + const optimistic = { + ...original, + content: [{ type: 'text' as const, text: 'edited' }], + deleted: true, + }; + queryClient.setQueryData>(queryKey, { + pages: [[optimistic]], + pageParams: [undefined], + }); + + restoreMessageInCache(queryClient, queryKey, original); + + const result = queryClient.getQueryData>(queryKey); + expect(result?.pages[0]?.[0]).toEqual(original); + }); + + it('creates the first reaction summary when adding a new emoji', () => { + expect(applyReactionAdded([], '👍', 'user-1')).toEqual([ + { emoji: '👍', count: 1, memberIds: ['user-1'] }, + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts new file mode 100644 index 0000000000..3bc038fc92 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMessageActionSheetOptions } from './message-actions'; + +describe('buildMessageActionSheetOptions', () => { + it('offers first-reaction choices for messages with no reactions', () => { + const options = buildMessageActionSheetOptions({ isOwnMessage: false, canReact: true }); + + expect(options.options).toContain('👍 React'); + expect(options.options).toContain('❤️ React'); + expect(options.cancelButtonIndex).toBe(options.options.length - 1); + }); + + it('offers edit and delete actions only for own messages', () => { + const ownOptions = buildMessageActionSheetOptions({ isOwnMessage: true, canReact: true }); + const otherOptions = buildMessageActionSheetOptions({ isOwnMessage: false, canReact: true }); + + expect(ownOptions.options).toContain('Edit'); + expect(ownOptions.options).toContain('Delete'); + expect(ownOptions.destructiveButtonIndex).toBe(ownOptions.options.indexOf('Delete')); + expect(otherOptions.options).not.toContain('Edit'); + expect(otherOptions.options).not.toContain('Delete'); + expect(otherOptions.destructiveButtonIndex).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts new file mode 100644 index 0000000000..d279ba1a29 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -0,0 +1,27 @@ +export const FIRST_REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉'] as const; + +type BuildMessageActionSheetOptionsInput = { + isOwnMessage: boolean; + canReact: boolean; +}; + +export function buildMessageActionSheetOptions({ + isOwnMessage, + canReact, +}: BuildMessageActionSheetOptionsInput): { + options: string[]; + cancelButtonIndex: number; + destructiveButtonIndex?: number; +} { + const options = [ + ...(canReact ? FIRST_REACTION_EMOJIS.map(emoji => `${emoji} React`) : []), + ...(isOwnMessage ? ['Edit', 'Delete'] : []), + 'Cancel', + ]; + const destructiveButtonIndex = isOwnMessage ? options.indexOf('Delete') : undefined; + return { + options, + cancelButtonIndex: options.length - 1, + ...(destructiveButtonIndex !== undefined && { destructiveButtonIndex }), + }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 9e6d0bf5eb..ba8c4fc605 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -1,4 +1,4 @@ -import { Send } from 'lucide-react-native'; +import { Send, X } from 'lucide-react-native'; import { useRef, useState } from 'react'; import { Pressable, TextInput, View } from 'react-native'; @@ -8,12 +8,14 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type Props = { onSend: (text: string) => void; disabled?: boolean; + initialText?: string; + onCancelEdit?: () => void; }; -export function MessageInput({ onSend, disabled }: Props) { +export function MessageInput({ onSend, disabled, initialText = '', onCancelEdit }: Props) { const colors = useThemeColors(); - const valueRef = useRef(''); - const [canSend, setCanSend] = useState(false); + const valueRef = useRef(initialText); + const [canSend, setCanSend] = useState(initialText.trim().length > 0); const inputRef = useRef(null); const submit = () => { @@ -34,6 +36,7 @@ export function MessageInput({ onSend, disabled }: Props) { className="flex-1 rounded-md border border-input bg-card px-3 py-2 leading-5 text-foreground" placeholder="Message" placeholderTextColor={colors.mutedForeground} + defaultValue={initialText} multiline onChangeText={t => { valueRef.current = t; @@ -41,6 +44,18 @@ export function MessageInput({ onSend, disabled }: Props) { }} onSubmitEditing={submit} /> + {onCancelEdit && ( + + + + )} Date: Fri, 1 May 2026 22:58:36 +0200 Subject: [PATCH 060/289] fix(kilo-chat): retry failed mark-read attempts --- .../kilo-chat/conversation-screen.tsx | 23 +++++-- .../kilo-chat/hooks/mark-read-operation.ts | 39 +++++++++++ .../kilo-chat/hooks/use-mark-read.ts | 30 ++++----- .../kilo-chat/mark-read-state.test.ts | 67 +++++++++++++++++++ .../claw/kilo-chat/components/MessageArea.tsx | 34 +++++++--- .../claw/kilo-chat/hooks/useConversations.ts | 5 ++ packages/kilo-chat-hooks/src/index.ts | 1 + .../kilo-chat-hooks/src/mark-read-state.ts | 29 ++++++++ 8 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts create mode 100644 apps/mobile/src/components/kilo-chat/mark-read-state.test.ts create mode 100644 packages/kilo-chat-hooks/src/mark-read-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 458ac58286..538bb7e395 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,5 +1,10 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { + createMarkReadState, + finishMarkReadAttempt, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, useAddReaction, useDeleteMessage, useEditMessage, @@ -200,17 +205,27 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const activeAndFocused = useAppActiveAndFocused(); const markRead = useMarkRead(client); - const lastMarkedRef = useRef(null); + const markReadStateRef = useRef(createMarkReadState()); useEffect(() => { if (!activeAndFocused) { return; } const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; - if (lastMarkedRef.current === marker) { + const state = markReadStateRef.current; + if (!shouldStartMarkReadAttempt(state, marker)) { return; } - lastMarkedRef.current = marker; - markRead(sandboxId, conversationId); + startMarkReadAttempt(state, marker); + void (async () => { + try { + await markRead(sandboxId, conversationId); + succeedMarkReadAttempt(state, marker); + } catch { + // useMarkRead already surfaces the mutation error to the user. + } finally { + finishMarkReadAttempt(state, marker); + } + })(); }, [activeAndFocused, conversationId, latestMessageId, markRead, sandboxId]); useFocusEffect( diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts new file mode 100644 index 0000000000..b45d8ed81b --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts @@ -0,0 +1,39 @@ +import { + type MarkBadgeReadInput, + type MarkBadgeReadResponse, + markBadgeReadResponseSchema, +} from '@kilocode/notifications'; + +type MarkReadConversationAndBadgeInput = { + conversationId: string; + badgeBucket: string; + notificationsUrl: string; + markConversationRead: (conversationId: string) => Promise; + getToken: () => Promise; + fetchBadgeRead: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export async function markReadConversationAndBadge({ + conversationId, + badgeBucket, + notificationsUrl, + markConversationRead, + getToken, + fetchBadgeRead, +}: MarkReadConversationAndBadgeInput): Promise { + await markConversationRead(conversationId); + const token = await getToken(); + const input = { badgeBucket } satisfies MarkBadgeReadInput; + const response = await fetchBadgeRead(`${notificationsUrl}/v1/badges/mark-read`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new Error(`Failed to mark badge read: ${response.status}`); + } + return markBadgeReadResponseSchema.parse(await response.json()); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index a235f3090b..507fd7c5f3 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -6,9 +6,7 @@ import { toast } from 'sonner-native'; import { badgeBucketForConversation, type BadgeCountRow, - type MarkBadgeReadInput, type MarkBadgeReadResponse, - markBadgeReadResponseSchema, } from '@kilocode/notifications'; import { type KiloChatClient } from '@kilocode/kilo-chat'; import { useMarkConversationRead } from '@kilocode/kilo-chat-hooks'; @@ -17,6 +15,7 @@ import { NOTIFICATIONS_URL } from '@/lib/config'; import { useCurrentUserId } from './use-current-user-id'; import { useKiloChatTokenGetter } from './use-kilo-chat-token'; +import { markReadConversationAndBadge } from './mark-read-operation'; type MarkReadContext = { previousBadges?: BadgeCountRow[]; @@ -40,21 +39,15 @@ export function useMarkRead(client: KiloChatClient) { conversationId, badgeBucket, }: MarkReadInput): Promise => { - await markConversationRead.mutateAsync(conversationId); - const token = await getToken(); - const input = { badgeBucket } satisfies MarkBadgeReadInput; - const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(input), + const result = await markReadConversationAndBadge({ + conversationId, + badgeBucket, + notificationsUrl: NOTIFICATIONS_URL, + markConversationRead: markConversationRead.mutateAsync, + getToken, + fetchBadgeRead: fetch, }); - if (!response.ok) { - throw new Error(`Failed to mark badge read: ${response.status}`); - } - return markBadgeReadResponseSchema.parse(await response.json()); + return result; }, onMutate: async ({ badgeBucket }): Promise => { if (userId === null) { @@ -90,12 +83,13 @@ export function useMarkRead(client: KiloChatClient) { }); return useCallback( - (sandboxId: string, conversationId: string) => { - mutation.mutate({ + async (sandboxId: string, conversationId: string) => { + const result = await mutation.mutateAsync({ sandboxId, conversationId, badgeBucket: badgeBucketForConversation(sandboxId, conversationId), }); + return result; }, [mutation] ); diff --git a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts new file mode 100644 index 0000000000..b116420546 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + createMarkReadState, + finishMarkReadAttempt, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, +} from '@kilocode/kilo-chat-hooks'; +import { markReadConversationAndBadge } from './hooks/mark-read-operation'; + +describe('mark-read attempt state', () => { + it('retries the same visible message after a failed attempt settles', () => { + const state = createMarkReadState(); + const marker = 'conversation-1:message-1'; + + expect(shouldStartMarkReadAttempt(state, marker)).toBe(true); + + startMarkReadAttempt(state, marker); + expect(shouldStartMarkReadAttempt(state, marker)).toBe(false); + + finishMarkReadAttempt(state, marker); + expect(shouldStartMarkReadAttempt(state, marker)).toBe(true); + }); + + it('does not retry the same visible message after a successful attempt settles', () => { + const state = createMarkReadState(); + const marker = 'conversation-1:message-1'; + + startMarkReadAttempt(state, marker); + succeedMarkReadAttempt(state, marker); + finishMarkReadAttempt(state, marker); + + expect(shouldStartMarkReadAttempt(state, marker)).toBe(false); + }); +}); + +describe('markReadConversationAndBadge', () => { + it('rejects when membership read succeeds but badge clearing fails', async () => { + let membershipReadCount = 0; + let badgeReadCount = 0; + + await expect( + markReadConversationAndBadge({ + conversationId: 'conversation-1', + badgeBucket: 'bucket-1', + notificationsUrl: 'https://notifications.example', + markConversationRead: async () => { + await Promise.resolve(); + membershipReadCount += 1; + }, + getToken: async () => { + await Promise.resolve(); + return 'token-1'; + }, + fetchBadgeRead: async () => { + await Promise.resolve(); + badgeReadCount += 1; + return new Response('{}', { status: 500 }); + }, + }) + ).rejects.toThrow('Failed to mark badge read: 500'); + + expect(membershipReadCount).toBe(1); + expect(badgeReadCount).toBe(1); + }); +}); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 9ea7e3ff48..7a8a619bae 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -27,9 +27,14 @@ import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useDocumentVisible } from '@/hooks/useDocumentVisible'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { + createMarkReadState, + finishMarkReadAttempt, useConversationDetail, useRenameConversation, useMarkConversationRead, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, } from '../hooks/useConversations'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; @@ -152,17 +157,30 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const sendTyping = useTypingSender(kiloChatClient, conversationId); const markRead = useMarkConversationRead(kiloChatClient); - const lastMarkedRef = useRef(null); + const markReadStateRef = useRef(createMarkReadState()); + const markCurrentConversationRead = useCallback(() => { + const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; + const state = markReadStateRef.current; + if (!shouldStartMarkReadAttempt(state, marker)) { + return; + } + startMarkReadAttempt(state, marker); + markRead.mutate(conversationId, { + onSuccess: () => { + succeedMarkReadAttempt(state, marker); + }, + onSettled: () => { + finishMarkReadAttempt(state, marker); + }, + }); + }, [conversationId, latestMessageId, markRead.mutate]); // Mark conversation as read when opened and whenever visible hydration or // realtime receipt advances the newest message. useEffect(() => { if (!visible) return; - const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; - if (lastMarkedRef.current === marker) return; - lastMarkedRef.current = marker; - markRead.mutate(conversationId); - }, [conversationId, latestMessageId, markRead.mutate, visible]); + markCurrentConversationRead(); + }, [markCurrentConversationRead, visible]); // Register side-effect handlers that don't mutate the message cache // (cache updates are handled by useMessageCacheUpdater). @@ -186,10 +204,10 @@ export function MessageArea({ conversationId }: MessageAreaProps) { return eventService.onReconnect(() => { void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'messages', conversationId] }); if (visible) { - markRead.mutate(conversationId); + markCurrentConversationRead(); } }); - }, [conversationId, eventService, markRead.mutate, queryClient, visible]); + }, [conversationId, eventService, markCurrentConversationRead, queryClient, visible]); // Auto-scroll whenever content height changes (new messages, streaming // updates, image loads). A ResizeObserver on the inner content fires only diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts index 221f56e395..780557feff 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts @@ -1,10 +1,15 @@ export { + createMarkReadState, + finishMarkReadAttempt, useConversations, useConversationDetail, useCreateConversation, useRenameConversation, useLeaveConversation, useMarkConversationRead, + shouldStartMarkReadAttempt, + startMarkReadAttempt, + succeedMarkReadAttempt, updateConversationPages, filterConversationPages, } from '@kilocode/kilo-chat-hooks'; diff --git a/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts index 599b343147..c8e4f5ab69 100644 --- a/packages/kilo-chat-hooks/src/index.ts +++ b/packages/kilo-chat-hooks/src/index.ts @@ -1,4 +1,5 @@ export * from './context'; +export * from './mark-read-state'; export * from './query-keys'; export * from './use-conversations'; export * from './use-messages'; diff --git a/packages/kilo-chat-hooks/src/mark-read-state.ts b/packages/kilo-chat-hooks/src/mark-read-state.ts new file mode 100644 index 0000000000..f3968d2d5a --- /dev/null +++ b/packages/kilo-chat-hooks/src/mark-read-state.ts @@ -0,0 +1,29 @@ +export type MarkReadState = { + lastSucceededMarker: string | null; + inFlightMarker: string | null; +}; + +export function createMarkReadState(): MarkReadState { + return { + lastSucceededMarker: null, + inFlightMarker: null, + }; +} + +export function shouldStartMarkReadAttempt(state: MarkReadState, marker: string): boolean { + return state.lastSucceededMarker !== marker && state.inFlightMarker !== marker; +} + +export function startMarkReadAttempt(state: MarkReadState, marker: string): void { + state.inFlightMarker = marker; +} + +export function succeedMarkReadAttempt(state: MarkReadState, marker: string): void { + state.lastSucceededMarker = marker; +} + +export function finishMarkReadAttempt(state: MarkReadState, marker: string): void { + if (state.inFlightMarker === marker) { + state.inFlightMarker = null; + } +} From 65ff5eb1254e5bea8bbf5dc3f8b5b1c6e67cae9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:00:22 +0200 Subject: [PATCH 061/289] fix(mobile): add reply and delivery failure parity --- .../kilo-chat/conversation-screen.tsx | 49 +++++++-- .../kilo-chat/message-actions.test.ts | 34 +++++- .../components/kilo-chat/message-actions.ts | 3 + .../components/kilo-chat/message-bubble.tsx | 21 ++++ .../components/kilo-chat/message-input.tsx | 102 ++++++++++++------ .../src/components/kilo-chat/message-list.tsx | 8 ++ .../kilo-chat/message-presentation.test.ts | 59 ++++++++++ .../kilo-chat/message-presentation.ts | 45 ++++++++ packages/kilo-chat-hooks/src/use-messages.ts | 14 ++- 9 files changed, 289 insertions(+), 46 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-presentation.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-presentation.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 538bb7e395..18e9fdebb9 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -23,6 +23,7 @@ import { ConversationHeader } from './conversation-header'; import { buildMessageActionSheetOptions, FIRST_REACTION_EMOJIS } from './message-actions'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; +import { buildSendMessageVariables } from './message-presentation'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; @@ -47,6 +48,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const { showActionSheetWithOptions } = useActionSheet(); const { bottom } = useSafeAreaInsets(); const [editingMessage, setEditingMessage] = useState(null); + const [replyingTo, setReplyingTo] = useState(null); const messagesQuery = useMessages(client, conversationId); const messages = messagesQuery.data?.messages ?? []; @@ -69,7 +71,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl [editingMessage] ); const handleSend = useCallback( - (text: string) => { + (text: string, inReplyToMessageId?: string) => { if (editingMessage) { editMessage.mutate( { @@ -89,11 +91,19 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl ); return; } - sendMutation.mutate({ - conversationId, - content: [{ type: 'text', text }], - clientId: Crypto.randomUUID(), - }); + sendMutation.mutate( + buildSendMessageVariables({ + conversationId, + text, + clientId: Crypto.randomUUID(), + inReplyToMessageId, + }), + { + onSuccess: () => { + setReplyingTo(null); + }, + } + ); }, [conversationId, editMessage, editingMessage, sendMutation] ); @@ -141,6 +151,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const actionSheet = buildMessageActionSheetOptions({ isOwnMessage, canReact: currentUserId !== null, + canReply: !message.deliveryFailed, }); showActionSheetWithOptions( { @@ -167,7 +178,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl return; } const selected = actionSheet.options[index]; + if (selected === 'Reply') { + setEditingMessage(null); + setReplyingTo(message); + return; + } if (selected === 'Edit') { + setReplyingTo(null); setEditingMessage(message); return; } @@ -201,7 +218,17 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const handleActionFailed = useCallback(() => { toast.error("Couldn't reach the bot — please try again"); }, []); - useMessageCacheUpdater(client, sandboxId, conversationId, undefined, handleActionFailed); + const handleMessageDeliveryFailed = useCallback(() => { + toast.error('Message could not be delivered to the bot'); + }, []); + useMessageCacheUpdater( + client, + sandboxId, + conversationId, + undefined, + handleActionFailed, + handleMessageDeliveryFailed + ); const activeAndFocused = useAppActiveAndFocused(); const markRead = useMarkRead(client); @@ -259,6 +286,14 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl onSend={handleSend} disabled={sendMutation.isPending || editMessage.isPending} initialText={editingText} + replyingTo={replyingTo} + onCancelReply={ + replyingTo + ? () => { + setReplyingTo(null); + } + : undefined + } onCancelEdit={ editingMessage ? () => { diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts index 3bc038fc92..b5804bb002 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -4,7 +4,11 @@ import { buildMessageActionSheetOptions } from './message-actions'; describe('buildMessageActionSheetOptions', () => { it('offers first-reaction choices for messages with no reactions', () => { - const options = buildMessageActionSheetOptions({ isOwnMessage: false, canReact: true }); + const options = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: true, + canReply: true, + }); expect(options.options).toContain('👍 React'); expect(options.options).toContain('❤️ React'); @@ -12,8 +16,16 @@ describe('buildMessageActionSheetOptions', () => { }); it('offers edit and delete actions only for own messages', () => { - const ownOptions = buildMessageActionSheetOptions({ isOwnMessage: true, canReact: true }); - const otherOptions = buildMessageActionSheetOptions({ isOwnMessage: false, canReact: true }); + const ownOptions = buildMessageActionSheetOptions({ + isOwnMessage: true, + canReact: true, + canReply: true, + }); + const otherOptions = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: true, + canReply: true, + }); expect(ownOptions.options).toContain('Edit'); expect(ownOptions.options).toContain('Delete'); @@ -22,4 +34,20 @@ describe('buildMessageActionSheetOptions', () => { expect(otherOptions.options).not.toContain('Delete'); expect(otherOptions.destructiveButtonIndex).toBeUndefined(); }); + + it('offers reply only when allowed for the message', () => { + const replyableOptions = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: true, + canReply: true, + }); + const failedDeliveryOptions = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: true, + canReply: false, + }); + + expect(replyableOptions.options).toContain('Reply'); + expect(failedDeliveryOptions.options).not.toContain('Reply'); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index d279ba1a29..4e17e7a4b7 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -3,11 +3,13 @@ export const FIRST_REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉'] as const type BuildMessageActionSheetOptionsInput = { isOwnMessage: boolean; canReact: boolean; + canReply: boolean; }; export function buildMessageActionSheetOptions({ isOwnMessage, canReact, + canReply, }: BuildMessageActionSheetOptionsInput): { options: string[]; cancelButtonIndex: number; @@ -15,6 +17,7 @@ export function buildMessageActionSheetOptions({ } { const options = [ ...(canReact ? FIRST_REACTION_EMOJIS.map(emoji => `${emoji} React`) : []), + ...(canReply ? ['Reply'] : []), ...(isOwnMessage ? ['Edit', 'Delete'] : []), 'Cancel', ]; diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index 2f81413229..6f911b99a9 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -5,6 +5,7 @@ import { Pressable, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; +import { getDeliveryFailureLabel, getReplyPreviewText } from './message-presentation'; type Props = { message: Message; @@ -12,6 +13,7 @@ type Props = { isFromMe: boolean; showAuthor: boolean; isExecutingAction: boolean; + replyToMessage?: Message | null; onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; onReactionPress: (message: Message, emoji: string) => void; onLongPress?: (m: Message) => void; @@ -39,6 +41,7 @@ function MessageBubbleComponent({ isFromMe, showAuthor, isExecutingAction, + replyToMessage, onExecuteAction, onReactionPress, onLongPress, @@ -55,6 +58,7 @@ function MessageBubbleComponent({ } const textColor = isFromMe ? 'text-primary-foreground' : 'text-foreground'; + const deliveryFailureLabel = getDeliveryFailureLabel(message); return ( [deleted message] ) : ( <> + {replyToMessage && ( + + + {getReplyPreviewText(replyToMessage)} + + + )} {message.content.map((block, index) => { if (block.type === 'text') { return ( @@ -124,6 +140,11 @@ function MessageBubbleComponent({ ); })} + {deliveryFailureLabel && ( + + {deliveryFailureLabel} + + )} )} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index ba8c4fc605..209265ebae 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -1,18 +1,30 @@ import { Send, X } from 'lucide-react-native'; import { useRef, useState } from 'react'; import { Pressable, TextInput, View } from 'react-native'; +import { type Message } from '@kilocode/kilo-chat'; +import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getReplyPreviewText } from './message-presentation'; type Props = { - onSend: (text: string) => void; + onSend: (text: string, inReplyToMessageId?: string) => void; disabled?: boolean; initialText?: string; onCancelEdit?: () => void; + replyingTo?: Message | null; + onCancelReply?: () => void; }; -export function MessageInput({ onSend, disabled, initialText = '', onCancelEdit }: Props) { +export function MessageInput({ + onSend, + disabled, + initialText = '', + onCancelEdit, + replyingTo, + onCancelReply, +}: Props) { const colors = useThemeColors(); const valueRef = useRef(initialText); const [canSend, setCanSend] = useState(initialText.trim().length > 0); @@ -23,49 +35,71 @@ export function MessageInput({ onSend, disabled, initialText = '', onCancelEdit if (!text) { return; } - onSend(text); + onSend(text, replyingTo?.id); valueRef.current = ''; inputRef.current?.clear(); setCanSend(false); }; return ( - - { - valueRef.current = t; - setCanSend(t.trim().length > 0); - }} - onSubmitEditing={submit} - /> - {onCancelEdit && ( + + {replyingTo && ( + + + Replying to + + {getReplyPreviewText(replyingTo)} + + + + + + + )} + + { + valueRef.current = t; + setCanSend(t.trim().length > 0); + }} + onSubmitEditing={submit} + /> + {onCancelEdit && ( + + + + )} - + - )} - - - + ); } diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index c12f50cb0f..85ff55ecd0 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -1,5 +1,6 @@ import { FlashList } from '@shopify/flash-list'; import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { useMemo } from 'react'; import { View } from 'react-native'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; @@ -30,6 +31,10 @@ export function MessageList({ // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition // with startRenderingFromBottom, which expects chronological order. const chronological = messages; + const messageMap = useMemo( + () => new Map(chronological.map(message => [message.id, message])), + [chronological] + ); return ( = {}): Message { + return { + id: 'message-1', + senderId: 'user-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + ...overrides, + }; +} + +describe('buildSendMessageVariables', () => { + it('includes inReplyToMessageId when sending a reply', () => { + expect( + buildSendMessageVariables({ + conversationId: 'conversation-1', + text: 'reply body', + clientId: 'client-1', + inReplyToMessageId: 'parent-1', + }) + ).toEqual({ + conversationId: 'conversation-1', + content: [{ type: 'text', text: 'reply body' }], + clientId: 'client-1', + inReplyToMessageId: 'parent-1', + }); + }); +}); + +describe('getReplyPreviewText', () => { + it('uses parent text for a reply preview', () => { + expect(getReplyPreviewText(message({ content: [{ type: 'text', text: 'parent text' }] }))).toBe( + 'parent text' + ); + }); + + it('uses a deleted-message label for deleted parents', () => { + expect(getReplyPreviewText(message({ deleted: true }))).toBe('[deleted message]'); + }); +}); + +describe('getDeliveryFailureLabel', () => { + it('returns a visible failure label for failed delivery messages', () => { + expect(getDeliveryFailureLabel(message({ deliveryFailed: true }))).toBe('Not delivered'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts new file mode 100644 index 0000000000..a1f749a712 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -0,0 +1,45 @@ +import { type CreateMessageRequest, type Message } from '@kilocode/kilo-chat'; + +export type SendMessageVariables = CreateMessageRequest & { clientId: string }; + +type BuildSendMessageVariablesInput = { + conversationId: string; + text: string; + clientId: string; + inReplyToMessageId?: string; +}; + +export function buildSendMessageVariables({ + conversationId, + text, + clientId, + inReplyToMessageId, +}: BuildSendMessageVariablesInput): SendMessageVariables { + const content: CreateMessageRequest['content'] = [{ type: 'text', text }]; + return { + conversationId, + content, + clientId, + ...(inReplyToMessageId ? { inReplyToMessageId } : {}), + }; +} + +export function contentBlocksToPreviewText(content: Message['content']): string { + const preview = content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + .trim(); + return preview || 'Message'; +} + +export function getReplyPreviewText(replyToMessage: Message): string { + if (replyToMessage.deleted) { + return '[deleted message]'; + } + return contentBlocksToPreviewText(replyToMessage.content); +} + +export function getDeliveryFailureLabel(message: Message): string | null { + return message.deliveryFailed ? 'Not delivered' : null; +} diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 5cddfb35e3..89590c2b74 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -413,7 +413,8 @@ export function useMessageCacheUpdater( // this conversation, after the optimistic resolved-state has been rolled // back. The shared package is platform-agnostic, so the user-visible // message lives at the call site (web: sonner toast; mobile: native toast). - onActionFailed?: () => void + onActionFailed?: () => void, + onMessageDeliveryFailed?: () => void ): void { const queryClient = useQueryClient(); @@ -459,6 +460,7 @@ export function useMessageCacheUpdater( if (!old) return old; return updateMessageInPages(old, e.messageId, msg => ({ ...msg, deliveryFailed: true })); }); + onMessageDeliveryFailed?.(); }; const onActionDeliveryFailed = (ctx: string, e: ActionDeliveryFailedEvent) => { @@ -511,5 +513,13 @@ export function useMessageCacheUpdater( return () => { for (const off of offs) off(); }; - }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated, onActionFailed]); + }, [ + client, + sandboxId, + conversationId, + queryClient, + onHumanMessageCreated, + onActionFailed, + onMessageDeliveryFailed, + ]); } From e05c9eb78e7a9ce6fe0839afac761d48fac6100e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:11:16 +0200 Subject: [PATCH 062/289] fix(event-service): retry after token refresh --- .../kilo-chat/hooks/use-kilo-chat-token.ts | 5 + .../kilo-chat/kilo-chat-provider.tsx | 5 + apps/web/src/contexts/EventServiceContext.tsx | 6 +- .../src/__tests__/client.test.ts | 101 +++++++++++++++++- packages/event-service/src/client.ts | 60 ++++++++--- packages/event-service/src/types.ts | 13 ++- 6 files changed, 162 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts index 583ff2ac6a..f95b384987 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -18,6 +18,11 @@ type TokenCache = { let cache: TokenCache | null = null; let inFlight: { authToken: string; promise: Promise } | null = null; +export function clearKiloChatTokenCache(): void { + cache = null; + inFlight = null; +} + /** * Returns a stable getter function that fetches a kilo-chat JWT, caching it * until 60 seconds before expiry. Concurrent callers share a single in-flight diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index 9b08023c7f..409e7c6f51 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -7,6 +7,7 @@ import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks'; import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; import { + clearKiloChatTokenCache, useKiloChatTokenGetter, useKiloChatTokenResponseGetter, } from './hooks/use-kilo-chat-token'; @@ -26,6 +27,10 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { const eventService = new EventServiceClient({ url: EVENT_SERVICE_URL, getToken, + onUnauthorized: () => { + clearKiloChatTokenCache(); + return 'retry'; + }, }); const kiloChatClient = new KiloChatClient({ eventService, diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx index 31b0318c6c..fdbb075507 100644 --- a/apps/web/src/contexts/EventServiceContext.tsx +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -30,11 +30,11 @@ export function EventServiceProvider({ children }: EventServiceProviderProps) { new EventServiceClient({ url: EVENT_SERVICE_URL, getToken: getKiloChatToken, - // Event Service rejected our token as 401/403. Drop the cached - // token so the next request refetches; the socket is permanently - // stopped by the client to avoid a reconnect storm. + // Event Service rejected our token as 401/403. Drop the cached token + // so the bounded reconnect path refetches a fresh one. onUnauthorized: () => { clearKiloChatToken(); + return 'retry'; }, }), [] diff --git a/packages/event-service/src/__tests__/client.test.ts b/packages/event-service/src/__tests__/client.test.ts index 01fe89f415..0aa1c88589 100644 --- a/packages/event-service/src/__tests__/client.test.ts +++ b/packages/event-service/src/__tests__/client.test.ts @@ -210,10 +210,60 @@ describe('EventServiceClient', () => { expect(allMockWs).toHaveLength(2); }); - it('error before open calls onUnauthorized and stops reconnecting', async () => { + it('refreshes a stale token once after a pre-open rejection and reconnects', async () => { vi.useFakeTimers(); try { - const onUnauthorized = vi.fn(); + const tokens = ['stale.token.sig', 'fresh.token.sig']; + const getToken = vi.fn(async () => tokens.shift() ?? 'fresh.token.sig'); + const retryAuth = (): 'retry' => 'retry'; + const onUnauthorized = vi.fn(retryAuth); + let wsCount = 0; + const WebSocketMock = function (url: string, protocols?: string | string[]) { + lastMockWs = new MockWebSocket(url, protocols); + allMockWs.push(lastMockWs); + wsCount++; + if (wsCount === 1) { + lastMockWs.readyState = 0; // CONNECTING + void Promise.resolve().then(() => { + lastMockWs.triggerError(); + lastMockWs.triggerClose(); + }); + } else { + void Promise.resolve().then(() => lastMockWs.triggerOpen()); + } + return lastMockWs; + }; + WebSocketMock.OPEN = 1; + WebSocketMock.CLOSING = 2; + WebSocketMock.CLOSED = 3; + vi.stubGlobal('WebSocket', WebSocketMock); + + const client = new EventServiceClient({ + url: 'ws://localhost:8080', + getToken, + onUnauthorized, + }); + + await client.connect(); + + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(client.isConnected()).toBe(false); + + await vi.advanceTimersByTimeAsync(2_000); + expect(allMockWs).toHaveLength(2); + expect(getToken).toHaveBeenCalledTimes(2); + expect(allMockWs[1]?.protocols).toEqual([`kilo.jwt.${encodeBase64Url('fresh.token.sig')}`]); + expect(client.isConnected()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('stops after bounded auth recovery attempts keep failing', async () => { + vi.useFakeTimers(); + try { + const retryAuth = (): 'retry' => 'retry'; + const onUnauthorized = vi.fn(retryAuth); const WebSocketMock = function (url: string, protocols?: string | string[]) { lastMockWs = new MockWebSocket(url, protocols); allMockWs.push(lastMockWs); @@ -236,13 +286,54 @@ describe('EventServiceClient', () => { }); await client.connect(); + await vi.advanceTimersByTimeAsync(2_000); - expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized).toHaveBeenCalledTimes(2); + expect(allMockWs).toHaveLength(2); expect(client.isConnected()).toBe(false); - // No reconnect should be scheduled — advancing time keeps the count at 1. await vi.advanceTimersByTimeAsync(60_000); - expect(allMockWs).toHaveLength(1); + expect(allMockWs).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it('retries non-auth pre-open failures with normal backoff', async () => { + vi.useFakeTimers(); + try { + let wsCount = 0; + const WebSocketMock = function (url: string, protocols?: string | string[]) { + lastMockWs = new MockWebSocket(url, protocols); + allMockWs.push(lastMockWs); + wsCount++; + if (wsCount === 1) { + lastMockWs.readyState = 0; // CONNECTING + void Promise.resolve().then(() => { + lastMockWs.triggerError(); + lastMockWs.triggerClose(); + }); + } else { + void Promise.resolve().then(() => lastMockWs.triggerOpen()); + } + return lastMockWs; + }; + WebSocketMock.OPEN = 1; + WebSocketMock.CLOSING = 2; + WebSocketMock.CLOSED = 3; + vi.stubGlobal('WebSocket', WebSocketMock); + + const client = new EventServiceClient({ + url: 'ws://localhost:8080', + getToken: () => Promise.resolve('h.p.s'), + }); + + await client.connect(); + expect(client.isConnected()).toBe(false); + + await vi.advanceTimersByTimeAsync(2_000); + expect(allMockWs).toHaveLength(2); + expect(client.isConnected()).toBe(true); } finally { vi.useRealTimers(); } diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 5a9490bf2d..54fd74a5f4 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -33,6 +33,7 @@ export class HandshakeTimeoutError extends Error { } const HANDSHAKE_TIMEOUT_MS = 10_000; +const MAX_AUTH_RECOVERY_ATTEMPTS = 1; function encodeBase64Url(input: string): string { // btoa handles each char as a single byte; JWTs are ASCII so this is safe. @@ -43,7 +44,7 @@ function encodeBase64Url(input: string): string { export class EventServiceClient { private readonly url: string; private readonly getToken: () => Promise; - private readonly onUnauthorized: (() => void) | undefined; + private readonly onUnauthorized: EventServiceConfig['onUnauthorized']; private ws: WebSocket | null = null; private connected = false; @@ -56,11 +57,13 @@ export class EventServiceClient { private reconnectTimer: ReturnType | null = null; private destroyed = false; private reconnectAttempts = 0; + private authRecoveryAttempts = 0; private hasConnectedBefore = false; private reconnectHandlers = new Set<() => void>(); private pingTimer: ReturnType | null = null; private handshakeTimer: ReturnType | null = null; private abortHandshake: ((err: Error) => void) | null = null; + private suppressNextCloseReconnect = false; constructor(config: EventServiceConfig) { this.url = config.url; @@ -71,6 +74,7 @@ export class EventServiceClient { async connect(): Promise { this.destroyed = false; this.reconnectAttempts = 0; + this.authRecoveryAttempts = 0; if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; @@ -78,24 +82,39 @@ export class EventServiceClient { try { await this.connectOnce(); } catch (err) { - if (this.handleAuthFailure(err)) return; + if (await this.handleAuthFailure(err)) return; if (!this.destroyed) { this.scheduleReconnect(); } } } - private handleAuthFailure(err: unknown): boolean { - if (err instanceof WebSocketAuthError) { - this.destroyed = true; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.onUnauthorized?.(); + private async handleAuthFailure(err: unknown): Promise { + if (!(err instanceof WebSocketAuthError) || !this.onUnauthorized) { + return false; + } + + const decision = await this.onUnauthorized(); + if ( + decision === 'stop' || + this.destroyed || + this.authRecoveryAttempts >= MAX_AUTH_RECOVERY_ATTEMPTS + ) { + this.stopAfterUnauthorized(); return true; } - return false; + + this.authRecoveryAttempts++; + this.scheduleReconnect(); + return true; + } + + private stopAfterUnauthorized(): void { + this.destroyed = true; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } } private async connectOnce(): Promise { @@ -153,6 +172,7 @@ export class EventServiceClient { this.connected = true; this.hasConnectedBefore = true; this.reconnectAttempts = 0; + this.authRecoveryAttempts = 0; this.resubscribeContexts(); if (isReconnect) { for (const handler of this.reconnectHandlers) { @@ -172,6 +192,10 @@ export class EventServiceClient { this.connected = false; this.stopPing(); this.clearHandshakeTimer(); + if (this.suppressNextCloseReconnect) { + this.suppressNextCloseReconnect = false; + return; + } if (!this.destroyed) { this.scheduleReconnect(); } @@ -185,6 +209,7 @@ export class EventServiceClient { // errors as potential auth failures and surface them via // onUnauthorized. Callers can refresh the token and reconnect. if (!this.connected) { + this.suppressNextCloseReconnect = true; settleReject(new WebSocketAuthError()); } }); @@ -343,12 +368,15 @@ export class EventServiceClient { this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connectOnce().catch(err => { - // If the handshake failed with auth rejection, stop reconnecting. - if (this.handleAuthFailure(err)) return; - if (!this.destroyed) { - this.scheduleReconnect(); - } + void this.handleReconnectFailure(err); }); }, delay); } + + private async handleReconnectFailure(err: unknown): Promise { + if (await this.handleAuthFailure(err)) return; + if (!this.destroyed) { + this.scheduleReconnect(); + } + } } diff --git a/packages/event-service/src/types.ts b/packages/event-service/src/types.ts index 80400d42bc..a3777d1244 100644 --- a/packages/event-service/src/types.ts +++ b/packages/event-service/src/types.ts @@ -22,14 +22,19 @@ export type ServerMessage = z.infer; // ── Config ───────────────────────────────────────────────────────── +export type UnauthorizedRecoveryDecision = 'retry' | 'stop'; + export type EventServiceConfig = { url: string; getToken: () => Promise; /** * Called when the WebSocket upgrade is rejected (typically 401/403, though - * browsers do not expose the HTTP status of a failed handshake). The client - * marks itself destroyed and stops reconnecting; the caller should clear any - * cached token and trigger re-authentication. + * browsers do not expose the HTTP status of a failed handshake). Return + * 'retry' after clearing cached token state to let the client reconnect with + * a fresh token, or 'stop' to permanently stop reconnecting. */ - onUnauthorized?: () => void; + onUnauthorized?: () => + | void + | UnauthorizedRecoveryDecision + | Promise; }; From 2d49613085727921fca2207c3c1158a28662e811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:12:16 +0200 Subject: [PATCH 063/289] fix(mobile): dispatch message actions by identity --- .../kilo-chat/conversation-screen.tsx | 51 +++++++++---------- .../kilo-chat/message-actions.test.ts | 26 +++++++++- .../components/kilo-chat/message-actions.ts | 48 ++++++++++++++--- 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 18e9fdebb9..5dcf21b010 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -20,7 +20,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import { ConversationHeader } from './conversation-header'; -import { buildMessageActionSheetOptions, FIRST_REACTION_EMOJIS } from './message-actions'; +import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; import { buildSendMessageVariables } from './message-presentation'; @@ -162,13 +162,14 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl containerStyle: { paddingBottom: bottom }, }, index => { - if (index === undefined || index === actionSheet.cancelButtonIndex) { + const selectedAction = getSelectedMessageAction(actionSheet, index); + if (!selectedAction) { return; } - const reactionEmoji = FIRST_REACTION_EMOJIS[index]; - if (reactionEmoji) { + + if (selectedAction.kind === 'reaction') { addReaction.mutate( - { messageId: message.id, emoji: reactionEmoji }, + { messageId: message.id, emoji: selectedAction.emoji }, { onError: err => { toast.error(formatKiloChatError(err, 'Failed to add reaction')); @@ -177,36 +178,34 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl ); return; } - const selected = actionSheet.options[index]; - if (selected === 'Reply') { + if (selectedAction.kind === 'reply') { setEditingMessage(null); setReplyingTo(message); return; } - if (selected === 'Edit') { + if (selectedAction.kind === 'edit') { setReplyingTo(null); setEditingMessage(message); return; } - if (selected === 'Delete') { - Alert.alert('Delete message?', 'This will remove the message from the conversation.', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => { - deleteMessage.mutate( - { messageId: message.id, conversationId }, - { - onError: err => { - toast.error(formatKiloChatError(err, 'Failed to delete message')); - }, - } - ); - }, + + Alert.alert('Delete message?', 'This will remove the message from the conversation.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteMessage.mutate( + { messageId: message.id, conversationId }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to delete message')); + }, + } + ); }, - ]); - } + }, + ]); } ); }, diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts index b5804bb002..c51b45da31 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildMessageActionSheetOptions } from './message-actions'; +import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; describe('buildMessageActionSheetOptions', () => { it('offers first-reaction choices for messages with no reactions', () => { @@ -50,4 +50,28 @@ describe('buildMessageActionSheetOptions', () => { expect(replyableOptions.options).toContain('Reply'); expect(failedDeliveryOptions.options).not.toContain('Reply'); }); + + it('keeps reply as the first action when reactions are disabled', () => { + const actionSheet = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: false, + canReply: true, + }); + + expect(actionSheet.options).toEqual(['Reply', 'Cancel']); + expect(actionSheet.actions[0]).toEqual({ kind: 'reply', label: 'Reply' }); + }); + + it('resolves selected action by action identity instead of raw option index', () => { + const actionSheet = buildMessageActionSheetOptions({ + isOwnMessage: false, + canReact: false, + canReply: true, + }); + + const selectedAction = getSelectedMessageAction(actionSheet, 0); + + expect(selectedAction).toEqual({ kind: 'reply', label: 'Reply' }); + expect(selectedAction?.kind).not.toBe('reaction'); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index 4e17e7a4b7..0f6184df1a 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -1,5 +1,14 @@ export const FIRST_REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉'] as const; +type ReactionEmoji = (typeof FIRST_REACTION_EMOJIS)[number]; + +export type MessageAction = + | { kind: 'reaction'; label: string; emoji: ReactionEmoji } + | { kind: 'reply'; label: 'Reply' } + | { kind: 'edit'; label: 'Edit' } + | { kind: 'delete'; label: 'Delete' } + | { kind: 'cancel'; label: 'Cancel' }; + type BuildMessageActionSheetOptionsInput = { isOwnMessage: boolean; canReact: boolean; @@ -11,20 +20,47 @@ export function buildMessageActionSheetOptions({ canReact, canReply, }: BuildMessageActionSheetOptionsInput): { + actions: MessageAction[]; options: string[]; cancelButtonIndex: number; destructiveButtonIndex?: number; } { - const options = [ - ...(canReact ? FIRST_REACTION_EMOJIS.map(emoji => `${emoji} React`) : []), - ...(canReply ? ['Reply'] : []), - ...(isOwnMessage ? ['Edit', 'Delete'] : []), - 'Cancel', - ]; + const actions: MessageAction[] = []; + if (canReact) { + for (const emoji of FIRST_REACTION_EMOJIS) { + actions.push({ kind: 'reaction', label: `${emoji} React`, emoji }); + } + } + if (canReply) { + actions.push({ kind: 'reply', label: 'Reply' }); + } + if (isOwnMessage) { + actions.push({ kind: 'edit', label: 'Edit' }, { kind: 'delete', label: 'Delete' }); + } + actions.push({ kind: 'cancel', label: 'Cancel' }); + + const options = actions.map(action => action.label); const destructiveButtonIndex = isOwnMessage ? options.indexOf('Delete') : undefined; return { + actions, options, cancelButtonIndex: options.length - 1, ...(destructiveButtonIndex !== undefined && { destructiveButtonIndex }), }; } + +export function getSelectedMessageAction( + actionSheet: ReturnType, + index: number | undefined +): Exclude | null { + if (index === undefined || index === actionSheet.cancelButtonIndex) { + return null; + } + + const action = actionSheet.actions[index]; + if (!action || action.kind === 'cancel') { + return null; + } + + return action; +} From 713afbf291d7ea37c1b443b3dc598dd55377d0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:21:18 +0200 Subject: [PATCH 064/289] fix(kiloclaw): disable bot-added chat members --- services/kiloclaw/plugins/kilo-chat/src/approval.ts | 8 ++------ services/kiloclaw/plugins/kilo-chat/src/channel.test.ts | 4 ++-- services/kiloclaw/plugins/kilo-chat/src/channel.ts | 5 ----- .../kilo-chat/src/create-conversation-action.test.ts | 4 ++-- .../plugins/kilo-chat/src/create-conversation-action.ts | 9 +-------- 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/services/kiloclaw/plugins/kilo-chat/src/approval.ts b/services/kiloclaw/plugins/kilo-chat/src/approval.ts index 037e46de37..a3c2ab3e9d 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/approval.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/approval.ts @@ -212,12 +212,8 @@ export function createKiloChatApprovalCapability(): ChannelApprovalCapability { // Authorization is enforced by the kilo-chat Worker's execute-action // endpoint: callerId is derived from the JWT and the DO checks membership. // This callback is intentionally permissive because the Worker is the trust - // boundary. - // - // IMPORTANT: the Worker currently checks *membership*, not *session - // ownership*. If multi-party conversations ship (additionalMembers), any - // member could approve exec actions running on the conversation-owner's Fly - // machine. Gate on session ownership before enabling multi-party. + // boundary, and KiloClaw currently keeps bot-created approval conversations + // owner-only by not forwarding additionalMembers. authorizeActorAction: () => ({ authorized: true }), getActionAvailabilityState: () => ({ kind: 'enabled' as const }), diff --git a/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts b/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts index b67ac8d354..2b2d465bc5 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts @@ -133,12 +133,12 @@ describe('kilo-chat actions adapter', () => { expect(discovery?.actions).toContain('channel-create'); }); - it('describeMessageTool returns schema contribution for additionalMembers', () => { + it('describeMessageTool does not expose additionalMembers until approval authorization supports groups', () => { const adapter = kiloChatPlugin.actions; const discovery = adapter!.describeMessageTool?.({ cfg: {} as never, accountId: null }); expect(discovery?.schema).toBeDefined(); const schema = Array.isArray(discovery?.schema) ? discovery.schema[0] : discovery?.schema; - expect(schema?.properties).toHaveProperty('additionalMembers'); + expect(schema?.properties).not.toHaveProperty('additionalMembers'); expect(schema?.properties).toHaveProperty('groupId'); expect(schema?.properties).toHaveProperty('target'); expect(schema?.visibility).toBe('current-channel'); diff --git a/services/kiloclaw/plugins/kilo-chat/src/channel.ts b/services/kiloclaw/plugins/kilo-chat/src/channel.ts index d417f3ec6e..b135dcf42f 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/channel.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/channel.ts @@ -173,11 +173,6 @@ export const kiloChatPlugin = createChatChannelPlugin({ ] as const, schema: { properties: { - additionalMembers: Type.Optional( - Type.String({ - description: 'Comma-separated member IDs to add when creating a conversation.', - }) - ), groupId: Type.Optional( Type.String({ description: diff --git a/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.test.ts b/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.test.ts index 2ca5dffcf1..43384f762a 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.test.ts @@ -51,7 +51,7 @@ describe('handleKiloChatCreateConversationAction', () => { expect(result.content[0].text).toBe('Created conversation 01NEWCONV'); }); - it('parses comma-separated additionalMembers IDs', async () => { + it('does not forward additionalMembers for bot-created conversations', async () => { const client = mockClient(); await handleKiloChatCreateConversationAction({ @@ -61,7 +61,7 @@ describe('handleKiloChatCreateConversationAction', () => { expect(client.createConversation).toHaveBeenCalledWith({ title: 'Group', - additionalMembers: ['user_1', 'user_2', 'user_3'], + additionalMembers: undefined, }); }); }); diff --git a/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.ts b/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.ts index 84b04104fb..2cd113aa25 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/create-conversation-action.ts @@ -10,17 +10,10 @@ export async function handleKiloChatCreateConversationAction( args: HandleKiloChatCreateConversationActionParams ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { const name = readStringParam(args.params, 'name'); - const membersRaw = readStringParam(args.params, 'additionalMembers'); - const additionalMembers = membersRaw - ? membersRaw - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : undefined; const { conversationId } = await args.client.createConversation({ title: name, - additionalMembers, + additionalMembers: undefined, }); const text = name From 85c7573c33cbfe690d28a83b7730f6b8027c5b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:26:30 +0200 Subject: [PATCH 065/289] fix(mobile): show chat action errors --- .../kilo-chat/conversation-screen.tsx | 3 +- .../kilo-chat/execute-action-feedback.test.ts | 88 +++++++++++++++++++ .../kilo-chat/execute-action-feedback.ts | 36 ++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/execute-action-feedback.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 5dcf21b010..5b7978bfa7 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -20,6 +20,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import { ConversationHeader } from './conversation-header'; +import { executeActionWithMobileFeedback } from './execute-action-feedback'; import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; @@ -138,7 +139,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl ); const handleExecuteAction = useCallback( (message: Message, groupId: string, value: ExecApprovalDecision) => { - executeAction.mutate({ messageId: message.id, groupId, value }); + executeActionWithMobileFeedback({ executeAction, message, groupId, value }); }, [executeAction] ); diff --git a/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts b/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts new file mode 100644 index 0000000000..f3143038f3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/execute-action-feedback.test.ts @@ -0,0 +1,88 @@ +import { type InfiniteData, QueryClient } from '@tanstack/react-query'; +import { type Message } from '@kilocode/kilo-chat'; +import { messagesKey, restoreMessageInCache } from '@kilocode/kilo-chat-hooks'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner-native'; + +import { executeActionWithMobileFeedback } from './execute-action-feedback'; + +vi.mock('sonner-native', () => ({ + toast: { + error: vi.fn(), + }, +})); + +function actionMessage(overrides: Partial = {}): Message { + return { + id: 'message-1', + senderId: 'bot:sandbox-1', + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + }, + ], + inReplyToMessageId: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], + ...overrides, + }; +} + +describe('executeActionWithMobileFeedback', () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + }); + + it('shows a toast when the execute-action mutation reports an error', () => { + const mutate = vi.fn((_variables, options?: { onError?: (err: unknown) => void }) => { + options?.onError?.(new Error('offline')); + }); + + executeActionWithMobileFeedback({ + executeAction: { mutate }, + message: actionMessage(), + groupId: 'approval-1', + value: 'allow-once', + }); + + expect(mutate).toHaveBeenCalledWith( + { messageId: 'message-1', groupId: 'approval-1', value: 'allow-once' }, + { onError: expect.any(Function) } + ); + expect(toast.error).toHaveBeenCalledWith('Failed to execute action'); + }); + + it('restores an optimistically resolved action when shared rollback runs', () => { + const queryClient = new QueryClient(); + const queryKey = messagesKey('conversation-1'); + const original = actionMessage(); + const optimistic = actionMessage({ + content: [ + { + type: 'actions', + groupId: 'approval-1', + actions: [{ label: 'Allow once', style: 'primary', value: 'allow-once' }], + resolved: { + value: 'allow-once', + resolvedBy: 'user-1', + resolvedAt: 1, + }, + }, + ], + }); + queryClient.setQueryData>(queryKey, { + pages: [[optimistic]], + pageParams: [undefined], + }); + + restoreMessageInCache(queryClient, queryKey, original); + + const result = queryClient.getQueryData>(queryKey); + expect(result?.pages[0]?.[0]).toEqual(original); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts b/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts new file mode 100644 index 0000000000..a7c0d92f17 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/execute-action-feedback.ts @@ -0,0 +1,36 @@ +import { type ExecApprovalDecision, formatKiloChatError, type Message } from '@kilocode/kilo-chat'; +import { toast } from 'sonner-native'; + +type ExecuteActionVariables = { + messageId: string; + groupId: string; + value: ExecApprovalDecision; +}; + +type ExecuteActionMutation = { + mutate: ( + variables: ExecuteActionVariables, + options?: { onError?: (err: unknown) => void } + ) => void; +}; + +export function executeActionWithMobileFeedback({ + executeAction, + message, + groupId, + value, +}: { + executeAction: ExecuteActionMutation; + message: Message; + groupId: string; + value: ExecApprovalDecision; +}) { + executeAction.mutate( + { messageId: message.id, groupId, value }, + { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to execute action')); + }, + } + ); +} From b36d69a09fdc52cc6b332e241909b4175150f716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:26:40 +0200 Subject: [PATCH 066/289] feat(mobile): add chat typing indicators --- .../kilo-chat/conversation-screen.tsx | 14 +- .../kilo-chat/hooks/use-typing.test.ts | 87 +++++++ .../components/kilo-chat/hooks/use-typing.ts | 229 ++++++++++++++++++ .../kilo-chat/message-input-state.test.ts | 45 ++++ .../kilo-chat/message-input-state.ts | 42 ++++ .../components/kilo-chat/message-input.tsx | 26 +- .../components/kilo-chat/typing-indicator.tsx | 15 +- 7 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-typing.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-typing.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-input-state.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-input-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 5b7978bfa7..1345144966 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { useActionSheet } from '@expo/react-native-action-sheet'; import { createMarkReadState, @@ -27,11 +28,13 @@ import { MessageList } from './message-list'; import { buildSendMessageVariables } from './message-presentation'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; +import { useMobileTypingState, useTypingSender } from './hooks/use-typing'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; import { useAppActiveAndFocused } from './hooks/use-app-active-and-focused'; import { useMarkRead } from './hooks/use-mark-read'; import { useMessageCacheUpdater, useMessages, useSendMessage } from './hooks/use-messages'; import { useCurrentUserId } from './hooks/use-current-user-id'; +import { TypingIndicator } from './typing-indicator'; import { setActiveChatLocation } from '@/lib/notifications'; type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; @@ -67,6 +70,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const executeAction = useExecuteAction(client, conversationId, currentUserId); const addReaction = useAddReaction(client, conversationId, currentUserId); const removeReaction = useRemoveReaction(client, conversationId, currentUserId); + const { typingMembers, clearTypingForMember } = useMobileTypingState({ + client, + currentUserId, + sandboxId, + conversationId, + }); + const sendTyping = useTypingSender(client, conversationId); const editingText = useMemo( () => (editingMessage ? editableText(editingMessage) : ''), [editingMessage] @@ -225,7 +235,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl client, sandboxId, conversationId, - undefined, + clearTypingForMember, handleActionFailed, handleMessageDeliveryFailed ); @@ -281,9 +291,11 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl onLongPressMessage={handleLongPressMessage} onReactionPress={handleReactionPress} /> + { + const expectedContext = kiloclawConversationContext('sandbox-1', 'conversation-1'); + + it('tracks typing events for the active conversation and removes typing.stop members', () => { + const typingMembers = applyTypingStarted(new Map(), { + ctx: expectedContext, + event: { memberId: 'user-2' }, + currentUserId: 'user-1', + expectedContext, + now: 10, + }); + + expect([...typingMembers.entries()]).toEqual([['user-2', 10]]); + expect( + applyTypingStarted(typingMembers, { + ctx: kiloclawConversationContext('sandbox-1', 'other-conversation'), + event: { memberId: 'user-3' }, + currentUserId: 'user-1', + expectedContext, + now: 20, + }) + ).toBe(typingMembers); + expect( + applyTypingStarted(typingMembers, { + ctx: expectedContext, + event: { memberId: 'user-1' }, + currentUserId: 'user-1', + expectedContext, + now: 30, + }) + ).toBe(typingMembers); + + const stopped = applyTypingStopped(typingMembers, { + ctx: expectedContext, + memberId: 'user-2', + expectedContext, + }); + expect(stopped.size).toBe(0); + }); + + it('expires stale typing members', () => { + const typingMembers = new Map([ + ['recent-user', 4000], + ['stale-user', 1000], + ]); + + expect([...pruneStaleTypingMembers(typingMembers, 6000).keys()]).toEqual(['recent-user']); + }); + + it('sends typing pings at most once per cooldown window and swallows failures', async () => { + const sent: string[] = []; + const client = { + sendTyping: async (conversationId: string) => { + sent.push(conversationId); + await Promise.resolve(); + throw new Error('offline'); + }, + }; + + let lastSentAt = sendTypingPingIfDue({ + client, + conversationId: 'conversation-1', + lastSentAt: 0, + now: 4000, + }); + lastSentAt = sendTypingPingIfDue({ + client, + conversationId: 'conversation-1', + lastSentAt, + now: 5000, + }); + + await Promise.resolve(); + expect(sent).toEqual(['conversation-1']); + expect(lastSentAt).toBe(4000); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts b/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts new file mode 100644 index 0000000000..8ed867d97a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-typing.ts @@ -0,0 +1,229 @@ +import { kiloclawConversationContext } from '@kilocode/event-service'; +import { type KiloChatClient, type TypingEvent } from '@kilocode/kilo-chat'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const TYPING_COOLDOWN_MS = 3000; +const TYPING_DISPLAY_TIMEOUT_MS = 5000; + +type TypingSenderClient = { + sendTyping(conversationId: string): Promise; +}; + +export function applyTypingStarted( + typingMembers: Map, + { + ctx, + event, + currentUserId, + expectedContext, + now, + }: { + ctx: string; + event: TypingEvent; + currentUserId: string | null; + expectedContext: string | null; + now: number; + } +) { + if (!expectedContext || ctx !== expectedContext) { + return typingMembers; + } + if (event.memberId === currentUserId) { + return typingMembers; + } + + return new Map([...typingMembers, [event.memberId, now]]); +} + +export function applyTypingStopped( + typingMembers: Map, + { + ctx, + memberId, + expectedContext, + }: { + ctx: string; + memberId: string; + expectedContext: string | null; + } +) { + if (!expectedContext || ctx !== expectedContext) { + return typingMembers; + } + if (!typingMembers.has(memberId)) { + return typingMembers; + } + + const next = new Map(typingMembers); + next.delete(memberId); + return next; +} + +export function pruneStaleTypingMembers( + typingMembers: Map, + now: number, + timeoutMs = TYPING_DISPLAY_TIMEOUT_MS +) { + const next = new Map(); + for (const [memberId, lastSeenAt] of typingMembers) { + if (now - lastSeenAt < timeoutMs) { + next.set(memberId, lastSeenAt); + } + } + return next; +} + +export function sendTypingPingIfDue({ + client, + conversationId, + lastSentAt, + now, + cooldownMs = TYPING_COOLDOWN_MS, +}: { + client: TypingSenderClient; + conversationId: string | null; + lastSentAt: number; + now: number; + cooldownMs?: number; +}) { + if (!conversationId) { + return lastSentAt; + } + if (now - lastSentAt < cooldownMs) { + return lastSentAt; + } + + void (async () => { + try { + await client.sendTyping(conversationId); + } catch { + // Typing pings are best-effort and should never block composing. + } + })(); + return now; +} + +export function useTypingSender(client: KiloChatClient, conversationId: string | null) { + const lastSentAtRef = useRef(0); + + return useCallback(() => { + lastSentAtRef.current = sendTypingPingIfDue({ + client, + conversationId, + lastSentAt: lastSentAtRef.current, + now: Date.now(), + }); + }, [client, conversationId]); +} + +export function useMobileTypingState({ + client, + currentUserId, + sandboxId, + conversationId, +}: { + client: KiloChatClient; + currentUserId: string | null; + sandboxId: string | null; + conversationId: string | null; +}) { + const expectedContext = useMemo( + () => + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, + [conversationId, sandboxId] + ); + const [typingMembers, setTypingMembers] = useState>(new Map()); + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((memberId: string) => { + const timer = timersRef.current.get(memberId); + if (!timer) { + return; + } + clearTimeout(timer); + timersRef.current.delete(memberId); + }, []); + + const clearTypingForMember = useCallback( + (ctx: string, memberId: string) => { + if (!expectedContext || ctx !== expectedContext) { + return; + } + clearTimer(memberId); + setTypingMembers(prev => applyTypingStopped(prev, { ctx, memberId, expectedContext })); + }, + [clearTimer, expectedContext] + ); + + const handleTyping = useCallback( + (ctx: string, event: TypingEvent) => { + if (!expectedContext || ctx !== expectedContext) { + return; + } + if (event.memberId === currentUserId) { + return; + } + + const now = Date.now(); + setTypingMembers(prev => + applyTypingStarted(pruneStaleTypingMembers(prev, now), { + ctx, + event, + currentUserId, + expectedContext, + now, + }) + ); + + clearTimer(event.memberId); + const timer = setTimeout(() => { + setTypingMembers(prev => { + const next = new Map(prev); + next.delete(event.memberId); + return next; + }); + timersRef.current.delete(event.memberId); + }, TYPING_DISPLAY_TIMEOUT_MS); + timersRef.current.set(event.memberId, timer); + }, + [clearTimer, currentUserId, expectedContext] + ); + + useEffect(() => { + setTypingMembers(new Map()); + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + }, [expectedContext]); + + useEffect(() => { + if (!expectedContext) { + return undefined; + } + + const offs = [ + client.onTyping(handleTyping), + client.onTypingStop((ctx, event) => { + clearTypingForMember(ctx, event.memberId); + }), + ]; + return () => { + for (const off of offs) { + off(); + } + }; + }, [client, clearTypingForMember, expectedContext, handleTyping]); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, []); + + return { typingMembers, clearTypingForMember }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts new file mode 100644 index 0000000000..d9e8c05c24 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { applyMessageInputTextChange, submitMessageInputDraft } from './message-input-state'; + +describe('message input typing behavior', () => { + it('sends typing notifications on text changes without preventing normal send', () => { + const valueRef = { current: '' }; + const canSendValues: boolean[] = []; + const sentMessages: { text: string; replyTo?: string }[] = []; + let cleared = false; + let typingCount = 0; + + applyMessageInputTextChange({ + text: ' hello ', + valueRef, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + onTyping: () => { + typingCount += 1; + }, + }); + + const submitted = submitMessageInputDraft({ + valueRef, + replyingToMessageId: 'reply-1', + onSend: (text, replyTo) => { + sentMessages.push({ text, replyTo }); + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + }); + + expect(typingCount).toBe(1); + expect(submitted).toBe(true); + expect(sentMessages).toEqual([{ text: 'hello', replyTo: 'reply-1' }]); + expect(cleared).toBe(true); + expect(valueRef.current).toBe(''); + expect(canSendValues).toEqual([true, false]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.ts b/apps/mobile/src/components/kilo-chat/message-input-state.ts new file mode 100644 index 0000000000..a1de86ca24 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-state.ts @@ -0,0 +1,42 @@ +type DraftRef = { current: string }; + +export function applyMessageInputTextChange({ + text, + valueRef, + setCanSend, + onTyping, +}: { + text: string; + valueRef: DraftRef; + setCanSend: (canSend: boolean) => void; + onTyping?: () => void; +}) { + valueRef.current = text; + setCanSend(text.trim().length > 0); + onTyping?.(); +} + +export function submitMessageInputDraft({ + valueRef, + replyingToMessageId, + onSend, + clearInput, + setCanSend, +}: { + valueRef: DraftRef; + replyingToMessageId?: string; + onSend: (text: string, inReplyToMessageId?: string) => void; + clearInput: () => void; + setCanSend: (canSend: boolean) => void; +}) { + const text = valueRef.current.trim(); + if (!text) { + return false; + } + + onSend(text, replyingToMessageId); + valueRef.current = ''; + clearInput(); + setCanSend(false); + return true; +} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 209265ebae..e4b861978c 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -6,10 +6,12 @@ import { type Message } from '@kilocode/kilo-chat'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { applyMessageInputTextChange, submitMessageInputDraft } from './message-input-state'; import { getReplyPreviewText } from './message-presentation'; type Props = { onSend: (text: string, inReplyToMessageId?: string) => void; + onTyping?: () => void; disabled?: boolean; initialText?: string; onCancelEdit?: () => void; @@ -19,6 +21,7 @@ type Props = { export function MessageInput({ onSend, + onTyping, disabled, initialText = '', onCancelEdit, @@ -31,14 +34,13 @@ export function MessageInput({ const inputRef = useRef(null); const submit = () => { - const text = valueRef.current.trim(); - if (!text) { - return; - } - onSend(text, replyingTo?.id); - valueRef.current = ''; - inputRef.current?.clear(); - setCanSend(false); + submitMessageInputDraft({ + valueRef, + replyingToMessageId: replyingTo?.id, + onSend, + clearInput: () => inputRef.current?.clear(), + setCanSend, + }); }; return ( @@ -72,8 +74,12 @@ export function MessageInput({ defaultValue={initialText} multiline onChangeText={t => { - valueRef.current = t; - setCanSend(t.trim().length > 0); + applyMessageInputTextChange({ + text: t, + valueRef, + setCanSend, + onTyping, + }); }} onSubmitEditing={submit} /> diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator.tsx b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx index c909788c14..a03db68936 100644 --- a/apps/mobile/src/components/kilo-chat/typing-indicator.tsx +++ b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx @@ -1,15 +1,22 @@ import { View } from 'react-native'; import { Text } from '@/components/ui/text'; -type Props = { isTyping: boolean; name?: string }; +type Props = { typingMembers: Map }; -export function TypingIndicator({ isTyping, name }: Props) { - if (!isTyping) { +export function TypingIndicator({ typingMembers }: Props) { + if (typingMembers.size === 0) { return null; } + + const names = [...typingMembers.keys()].map(memberId => + memberId.startsWith('bot:') ? 'KiloClaw' : 'Someone' + ); + const text = + names.length === 1 ? `${names[0]} is typing...` : `${names.join(', ')} are typing...`; + return ( - {name ?? 'Bot'} is typing… + {text} ); } From f89353e40f5b70a2bfff65c2c19c917fcf8f67cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:35:17 +0200 Subject: [PATCH 067/289] fix(mobile): generate ulid chat client ids --- apps/mobile/package.json | 3 ++- .../kilo-chat/conversation-screen.tsx | 5 ++--- .../kilo-chat/message-presentation.test.ts | 13 +++++++++++- .../kilo-chat/message-presentation.ts | 5 +++++ packages/kilo-chat/test/events.test.ts | 10 ++++++++++ packages/kilo-chat/test/schemas.test.ts | 20 +++++++++++++++++++ pnpm-lock.yaml | 3 +++ 7 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 061695e27d..37731c5477 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -83,7 +83,8 @@ "react-native-worklets": "0.7.2", "sonner-native": "^0.23.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "ulid": "3.0.1" }, "devDependencies": { "@sentry/cli": "catalog:", diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 1345144966..5ad02beabc 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -13,7 +13,6 @@ import { useRemoveReaction, } from '@kilocode/kilo-chat-hooks'; import { type ExecApprovalDecision, formatKiloChatError, type Message } from '@kilocode/kilo-chat'; -import * as Crypto from 'expo-crypto'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; @@ -25,7 +24,7 @@ import { executeActionWithMobileFeedback } from './execute-action-feedback'; import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; import { MessageInput } from './message-input'; import { MessageList } from './message-list'; -import { buildSendMessageVariables } from './message-presentation'; +import { buildSendMessageVariables, createSendMessageClientId } from './message-presentation'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useMobileTypingState, useTypingSender } from './hooks/use-typing'; @@ -106,7 +105,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl buildSendMessageVariables({ conversationId, text, - clientId: Crypto.randomUUID(), + clientId: createSendMessageClientId(), inReplyToMessageId, }), { diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index 8c65b0b4d7..ec3de30dcb 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { type Message } from '@kilocode/kilo-chat'; +import { createMessageRequestSchema, type Message } from '@kilocode/kilo-chat'; import { buildSendMessageVariables, + createSendMessageClientId, getDeliveryFailureLabel, getReplyPreviewText, } from './message-presentation'; @@ -23,6 +24,16 @@ function message(overrides: Partial = {}): Message { } describe('buildSendMessageVariables', () => { + it('builds variables accepted by the create message request schema', () => { + const variables = buildSendMessageVariables({ + conversationId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + text: 'mobile message', + clientId: createSendMessageClientId(), + }); + + expect(createMessageRequestSchema.safeParse(variables).success).toBe(true); + }); + it('includes inReplyToMessageId when sending a reply', () => { expect( buildSendMessageVariables({ diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index a1f749a712..cefa803e37 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -1,4 +1,5 @@ import { type CreateMessageRequest, type Message } from '@kilocode/kilo-chat'; +import { ulid } from 'ulid'; export type SendMessageVariables = CreateMessageRequest & { clientId: string }; @@ -24,6 +25,10 @@ export function buildSendMessageVariables({ }; } +export function createSendMessageClientId(): string { + return ulid(); +} + export function contentBlocksToPreviewText(content: Message['content']): string { const preview = content .filter(block => block.type === 'text') diff --git a/packages/kilo-chat/test/events.test.ts b/packages/kilo-chat/test/events.test.ts index 43582506ea..05c58df0aa 100644 --- a/packages/kilo-chat/test/events.test.ts +++ b/packages/kilo-chat/test/events.test.ts @@ -6,6 +6,7 @@ const validConversationId = '01HXYZ00000ABCDEFGHJKMNPQR'; const validMessageId = '01HXYZ00000ABCDEFGHJKMNPQS'; const validReplyMessageId = '01HXYZ00000ABCDEFGHJKMNPQT'; const validClientId = '01HXYZ00000ABCDEFGHJKMNPQV'; +const uuidClientId = '8bb5a00b-98a3-4910-bda3-2669bcde23bc'; describe('kilo chat event payload schemas', () => { it('rejects malformed or empty event identifiers', () => { @@ -37,6 +38,15 @@ describe('kilo chat event payload schemas', () => { clientId: '', }).success ).toBe(false); + expect( + messageCreatedSchema.safeParse({ + messageId: validMessageId, + senderId: 'bot:kiloclaw:sandbox-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: validReplyMessageId, + clientId: uuidClientId, + }).success + ).toBe(false); const conversationSchema = getKiloChatEventPayloadSchema('conversation.created'); expect(conversationSchema.safeParse({ conversationId: 'not-a-ulid' }).success).toBe(false); diff --git a/packages/kilo-chat/test/schemas.test.ts b/packages/kilo-chat/test/schemas.test.ts index 7338db17ec..f18669792a 100644 --- a/packages/kilo-chat/test/schemas.test.ts +++ b/packages/kilo-chat/test/schemas.test.ts @@ -15,6 +15,8 @@ import { } from '../src/schemas'; const validConversationId = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; +const validClientId = '01HXYZ00000ABCDEFGHJKMNPQV'; +const uuidClientId = '8bb5a00b-98a3-4910-bda3-2669bcde23bc'; describe('title schemas — trim and reject empty', () => { describe('renameConversationRequestSchema', () => { @@ -148,6 +150,24 @@ describe('text content blocks — trim and reject empty', () => { }); describe('createMessageRequestSchema', () => { + it('accepts ULID client ids and rejects UUID client ids', () => { + const content = [{ type: 'text', text: 'hello' }]; + expect( + createMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content, + clientId: validClientId, + }).success + ).toBe(true); + expect( + createMessageRequestSchema.safeParse({ + conversationId: validConversationId, + content, + clientId: uuidClientId, + }).success + ).toBe(false); + }); + it('rejects whitespace-only text block', () => { const res = createMessageRequestSchema.safeParse({ conversationId: validConversationId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc88e9635d..4b585f9652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: tailwindcss: specifier: ^4.2.2 version: 4.2.2 + ulid: + specifier: 3.0.1 + version: 3.0.1 devDependencies: '@sentry/cli': specifier: ^3.3.4 From ead58ee32f1dfc7b29459c9bea452c0c176ceccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:37:01 +0200 Subject: [PATCH 068/289] fix(kilo-chat): reorder conversations after activity --- .../hooks/use-instance-event-subscription.ts | 16 ++--- .../kilo-chat/instance-event-cache.test.ts | 72 +++++++++++++++++-- .../kilo-chat/components/KiloChatLayout.tsx | 20 +++--- .../claw/kilo-chat/hooks/useConversations.ts | 1 + .../kilo-chat-hooks/src/use-conversations.ts | 58 ++++++++++++++- 5 files changed, 145 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts index de29ac0fd1..8cb0ce6406 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts @@ -10,6 +10,7 @@ import { conversationRenamedEventSchema, } from '@kilocode/kilo-chat'; import { + applyConversationActivityToPages, botStatusKey, conversationKey, type ConversationListInfiniteData, @@ -114,18 +115,15 @@ export function useInstanceEventSubscription(sandboxId: string | undefined) { return; } const event = result.data; - const data = qc.getQueryData(queryKey); - if (!isConversationOnFirstPage(data, event.conversationId)) { + const activityResult = applyConversationActivityToPages( + qc.getQueryData(queryKey), + event + ); + if (!activityResult.applied) { void qc.invalidateQueries({ queryKey }); return; } - qc.setQueryData(queryKey, old => - updateConversationPages(old, conversation => - conversation.conversationId === event.conversationId - ? { ...conversation, lastActivityAt: event.lastActivityAt } - : conversation - ) - ); + qc.setQueryData(queryKey, activityResult.data); }, [qc, queryKey] ); diff --git a/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts b/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts index dcf3b63aa0..b43f5f96c5 100644 --- a/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts +++ b/apps/mobile/src/components/kilo-chat/instance-event-cache.test.ts @@ -1,4 +1,7 @@ -import { type ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks'; +import { + applyConversationActivityToPages, + type ConversationListInfiniteData, +} from '@kilocode/kilo-chat-hooks'; import { describe, expect, it } from 'vitest'; import { @@ -6,13 +9,19 @@ import { shouldApplyConversationRead, } from './hooks/instance-event-cache'; -function conversation(conversationId: string) { +function conversation( + conversationId: string, + overrides: { + lastActivityAt?: number | null; + joinedAt?: number; + } = {} +) { return { conversationId, title: null, - lastActivityAt: null, + lastActivityAt: overrides.lastActivityAt ?? null, lastReadAt: null, - joinedAt: 1, + joinedAt: overrides.joinedAt ?? 1, }; } @@ -36,4 +45,59 @@ describe('instance event cache helpers', () => { expect(shouldApplyConversationRead('reader', 'other')).toBe(false); expect(shouldApplyConversationRead(null, 'reader')).toBe(false); }); + + it('moves a first-page conversation ahead after newer activity', () => { + const data: ConversationListInfiniteData = { + pages: [ + { + conversations: [ + conversation('01ARZ3NDEKTSV4RRFFQ69G5FA1', { lastActivityAt: 100, joinedAt: 100 }), + conversation('01ARZ3NDEKTSV4RRFFQ69G5FA2', { lastActivityAt: 90, joinedAt: 90 }), + ], + hasMore: false, + nextCursor: null, + }, + ], + pageParams: [null], + }; + + const result = applyConversationActivityToPages(data, { + conversationId: '01ARZ3NDEKTSV4RRFFQ69G5FA2', + lastActivityAt: 200, + }); + + expect(result.applied).toBe(true); + expect(result.data?.pages[0]?.conversations.map(c => c.conversationId)).toEqual([ + '01ARZ3NDEKTSV4RRFFQ69G5FA2', + '01ARZ3NDEKTSV4RRFFQ69G5FA1', + ]); + expect(result.data?.pages[0]?.conversations[0]?.lastActivityAt).toBe(200); + }); + + it('sorts equal activity timestamps by conversation id descending', () => { + const data: ConversationListInfiniteData = { + pages: [ + { + conversations: [ + conversation('01ARZ3NDEKTSV4RRFFQ69G5FA1', { lastActivityAt: 100, joinedAt: 100 }), + conversation('01ARZ3NDEKTSV4RRFFQ69G5FA2', { lastActivityAt: 90, joinedAt: 90 }), + ], + hasMore: false, + nextCursor: null, + }, + ], + pageParams: [null], + }; + + const result = applyConversationActivityToPages(data, { + conversationId: '01ARZ3NDEKTSV4RRFFQ69G5FA2', + lastActivityAt: 100, + }); + + expect(result.applied).toBe(true); + expect(result.data?.pages[0]?.conversations.map(c => c.conversationId)).toEqual([ + '01ARZ3NDEKTSV4RRFFQ69G5FA2', + '01ARZ3NDEKTSV4RRFFQ69G5FA1', + ]); + }); }); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 03be25a218..ed7ce0948a 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -16,6 +16,7 @@ import { useCreateConversation, useRenameConversation, useLeaveConversation, + applyConversationActivityToPages, updateConversationPages, filterConversationPages, type ConversationListInfiniteData, @@ -112,15 +113,18 @@ export function KiloChatLayout({ ); }), kiloChatClient.onConversationActivity((_ctx, e) => { - if (isOnFirstPage(e.conversationId)) { - queryClient.setQueriesData({ queryKey }, old => - updateConversationPages(old, c => - c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c - ) - ); - return; + let applied = false; + const entries = queryClient.getQueriesData({ queryKey }); + for (const [key, data] of entries) { + const result = applyConversationActivityToPages(data, e); + if (result.applied) { + applied = true; + queryClient.setQueryData(key, result.data); + } + } + if (!applied) { + void queryClient.invalidateQueries({ queryKey }); } - void queryClient.invalidateQueries({ queryKey }); }), ]; return () => offs.forEach(off => off()); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts index 780557feff..0db1d583f7 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts @@ -10,6 +10,7 @@ export { shouldStartMarkReadAttempt, startMarkReadAttempt, succeedMarkReadAttempt, + applyConversationActivityToPages, updateConversationPages, filterConversationPages, } from '@kilocode/kilo-chat-hooks'; diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 4079d7b9da..98e593d3ac 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -1,7 +1,11 @@ import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query'; import type { KiloChatClient } from '@kilocode/kilo-chat'; -import type { CreateConversationRequest, ConversationListResponse } from '@kilocode/kilo-chat'; +import type { + CreateConversationRequest, + ConversationListItem, + ConversationListResponse, +} from '@kilocode/kilo-chat'; import { conversationKey, conversationsKey, conversationsKeyAll, messagesKey } from './query-keys'; @@ -104,6 +108,58 @@ export function filterConversationPages( }; } +type ConversationActivity = { + conversationId: string; + lastActivityAt: number; +}; + +type ApplyConversationActivityResult = { + data: ConversationListInfiniteData | undefined; + applied: boolean; +}; + +function conversationActivitySortValue(conversation: ConversationListItem): number { + return conversation.lastActivityAt ?? conversation.joinedAt; +} + +function compareConversationsByActivity(a: ConversationListItem, b: ConversationListItem): number { + const timestampDelta = conversationActivitySortValue(b) - conversationActivitySortValue(a); + if (timestampDelta !== 0) return timestampDelta; + if (a.conversationId === b.conversationId) return 0; + return a.conversationId < b.conversationId ? 1 : -1; +} + +export function applyConversationActivityToPages( + data: ConversationListInfiniteData | undefined, + activity: ConversationActivity +): ApplyConversationActivityResult { + const firstPage = data?.pages[0]; + if (!data || !firstPage?.conversations.some(c => c.conversationId === activity.conversationId)) { + return { data, applied: false }; + } + + return { + data: { + ...data, + pages: data.pages.map((page, index) => + index === 0 + ? { + ...page, + conversations: page.conversations + .map(c => + c.conversationId === activity.conversationId + ? { ...c, lastActivityAt: activity.lastActivityAt } + : c + ) + .sort(compareConversationsByActivity), + } + : page + ), + }, + applied: true, + }; +} + export function useMarkConversationRead(client: KiloChatClient) { const queryClient = useQueryClient(); return useMutation({ From b472cbdb0d6b53f21ef515140148ae168a441020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:48:04 +0200 Subject: [PATCH 069/289] fix(kilo-chat): bound mark-read to observed message --- .../kilo-chat/conversation-screen.tsx | 6 +- .../kilo-chat/hooks/mark-read-operation.ts | 9 ++- .../kilo-chat/hooks/use-mark-read.ts | 6 +- .../kilo-chat/mark-read-state.test.ts | 1 + .../claw/kilo-chat/components/MessageArea.tsx | 24 ++++--- .../kilo-chat-hooks/src/use-conversations.ts | 9 ++- packages/kilo-chat/src/client.ts | 7 +- packages/kilo-chat/src/schemas.ts | 4 ++ packages/kilo-chat/src/types.ts | 2 + .../src/__tests__/messages-routes.test.ts | 68 ++++++++++++++++++- services/kilo-chat/src/do/membership-do.ts | 28 ++++++++ .../kilo-chat/src/routes/conversations.ts | 22 +++++- .../kilo-chat/src/services/conversations.ts | 28 ++++++-- 13 files changed, 186 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 5ad02beabc..4b7eaa8c35 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -243,10 +243,10 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const markRead = useMarkRead(client); const markReadStateRef = useRef(createMarkReadState()); useEffect(() => { - if (!activeAndFocused) { + if (!activeAndFocused || latestMessageId === null) { return; } - const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; + const marker = `${conversationId}:${latestMessageId}`; const state = markReadStateRef.current; if (!shouldStartMarkReadAttempt(state, marker)) { return; @@ -254,7 +254,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl startMarkReadAttempt(state, marker); void (async () => { try { - await markRead(sandboxId, conversationId); + await markRead(sandboxId, conversationId, latestMessageId); succeedMarkReadAttempt(state, marker); } catch { // useMarkRead already surfaces the mutation error to the user. diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts index b45d8ed81b..5e538a5500 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts @@ -6,22 +6,27 @@ import { type MarkReadConversationAndBadgeInput = { conversationId: string; + lastSeenMessageId: string; badgeBucket: string; notificationsUrl: string; - markConversationRead: (conversationId: string) => Promise; + markConversationRead: (input: { + conversationId: string; + lastSeenMessageId: string; + }) => Promise; getToken: () => Promise; fetchBadgeRead: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; export async function markReadConversationAndBadge({ conversationId, + lastSeenMessageId, badgeBucket, notificationsUrl, markConversationRead, getToken, fetchBadgeRead, }: MarkReadConversationAndBadgeInput): Promise { - await markConversationRead(conversationId); + await markConversationRead({ conversationId, lastSeenMessageId }); const token = await getToken(); const input = { badgeBucket } satisfies MarkBadgeReadInput; const response = await fetchBadgeRead(`${notificationsUrl}/v1/badges/mark-read`, { diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index 507fd7c5f3..961bb0bf53 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -25,6 +25,7 @@ type MarkReadContext = { type MarkReadInput = { sandboxId: string; conversationId: string; + lastSeenMessageId: string; badgeBucket: string; }; @@ -37,10 +38,12 @@ export function useMarkRead(client: KiloChatClient) { const mutation = useMutation({ mutationFn: async ({ conversationId, + lastSeenMessageId, badgeBucket, }: MarkReadInput): Promise => { const result = await markReadConversationAndBadge({ conversationId, + lastSeenMessageId, badgeBucket, notificationsUrl: NOTIFICATIONS_URL, markConversationRead: markConversationRead.mutateAsync, @@ -83,10 +86,11 @@ export function useMarkRead(client: KiloChatClient) { }); return useCallback( - async (sandboxId: string, conversationId: string) => { + async (sandboxId: string, conversationId: string, lastSeenMessageId: string) => { const result = await mutation.mutateAsync({ sandboxId, conversationId, + lastSeenMessageId, badgeBucket: badgeBucketForConversation(sandboxId, conversationId), }); return result; diff --git a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts index b116420546..195139c23d 100644 --- a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts @@ -43,6 +43,7 @@ describe('markReadConversationAndBadge', () => { await expect( markReadConversationAndBadge({ conversationId: 'conversation-1', + lastSeenMessageId: 'message-1', badgeBucket: 'bucket-1', notificationsUrl: 'https://notifications.example', markConversationRead: async () => { diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 7a8a619bae..1da6454387 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -159,20 +159,26 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const markRead = useMarkConversationRead(kiloChatClient); const markReadStateRef = useRef(createMarkReadState()); const markCurrentConversationRead = useCallback(() => { - const marker = `${conversationId}:${latestMessageId ?? 'empty'}`; + if (latestMessageId === null) { + return; + } + const marker = `${conversationId}:${latestMessageId}`; const state = markReadStateRef.current; if (!shouldStartMarkReadAttempt(state, marker)) { return; } startMarkReadAttempt(state, marker); - markRead.mutate(conversationId, { - onSuccess: () => { - succeedMarkReadAttempt(state, marker); - }, - onSettled: () => { - finishMarkReadAttempt(state, marker); - }, - }); + markRead.mutate( + { conversationId, lastSeenMessageId: latestMessageId }, + { + onSuccess: () => { + succeedMarkReadAttempt(state, marker); + }, + onSettled: () => { + finishMarkReadAttempt(state, marker); + }, + } + ); }, [conversationId, latestMessageId, markRead.mutate]); // Mark conversation as read when opened and whenever visible hydration or diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 98e593d3ac..c1c24e0efb 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -5,6 +5,7 @@ import type { CreateConversationRequest, ConversationListItem, ConversationListResponse, + MarkConversationReadRequest, } from '@kilocode/kilo-chat'; import { conversationKey, conversationsKey, conversationsKeyAll, messagesKey } from './query-keys'; @@ -163,8 +164,12 @@ export function applyConversationActivityToPages( export function useMarkConversationRead(client: KiloChatClient) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (conversationId: string) => client.markConversationRead(conversationId), - onMutate: conversationId => { + mutationFn: ({ + conversationId, + lastSeenMessageId, + }: MarkConversationReadRequest & { conversationId: string }) => + client.markConversationRead(conversationId, { lastSeenMessageId }), + onMutate: ({ conversationId }) => { // Optimistically set lastReadAt = now in all cached conversation lists const now = Date.now(); const queryKey = conversationsKeyAll(); diff --git a/packages/kilo-chat/src/client.ts b/packages/kilo-chat/src/client.ts index ea9e086489..8e78456c19 100644 --- a/packages/kilo-chat/src/client.ts +++ b/packages/kilo-chat/src/client.ts @@ -33,6 +33,7 @@ import type { EditMessageRequest, EditMessageResponse, RenameConversationRequest, + MarkConversationReadRequest, Message, MessageCreatedEvent, MessageUpdatedEvent, @@ -166,9 +167,13 @@ export class KiloChatClient { }); } - async markConversationRead(conversationId: string): Promise { + async markConversationRead( + conversationId: string, + req: MarkConversationReadRequest + ): Promise { await this.httpRequest(`/v1/conversations/${conversationId}/mark-read`, { method: 'POST', + body: req, schema: voidSchema, }); } diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 08479b725c..fd85b79774 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -205,6 +205,10 @@ export const renameConversationRequestSchema = z.object({ title: conversationTitleSchema, }); +export const markConversationReadRequestSchema = z.object({ + lastSeenMessageId: ulidSchema, +}); + export const executeActionRequestSchema = z.object({ groupId: actionGroupIdSchema, value: execApprovalDecisionSchema, diff --git a/packages/kilo-chat/src/types.ts b/packages/kilo-chat/src/types.ts index 3bc6737ea1..85fa4947cd 100644 --- a/packages/kilo-chat/src/types.ts +++ b/packages/kilo-chat/src/types.ts @@ -18,6 +18,7 @@ import type { editMessageRequestSchema, editMessageResponseSchema, deleteMessageRequestSchema, + markConversationReadRequestSchema, renameConversationRequestSchema, conversationListResponseSchema, messageListResponseSchema, @@ -109,6 +110,7 @@ export type CreateMessageResponse = z.infer; export type EditMessageRequest = z.infer; export type EditMessageResponse = z.infer; export type DeleteMessageRequest = z.infer; +export type MarkConversationReadRequest = z.infer; export type RenameConversationRequest = z.infer; export type OkResponse = z.infer; export type AddReactionResponse = z.infer; diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index 3718ad6a4f..4a2d11b565 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -1,5 +1,6 @@ import { env } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { ulidToTimestamp } from '@kilocode/kilo-chat'; import { ulid } from 'ulid'; import type { ConversationDO } from '../do/conversation-do'; import { makeApp } from './helpers'; @@ -940,10 +941,15 @@ describe('recipient conversation read state after message delivery', () => { deliveredEnv ); expect(createRes.status).toBe(201); + const { messageId } = await createRes.json<{ messageId: string }>(); const markReadRes = await recipientApp.request( `/v1/conversations/${conversationId}/mark-read`, - { method: 'POST' }, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ lastSeenMessageId: messageId }), + }, deliveredEnv ); expect(markReadRes.status).toBe(204); @@ -957,6 +963,66 @@ describe('recipient conversation read state after message delivery', () => { expect(conversation.lastActivityAt).not.toBeNull(); expect(conversation.lastReadAt).not.toBeNull(); }); + + it('marks recipients read only through the latest message the client observed', async () => { + const { conversationId, recipientId, sandboxId, userApp, recipientApp, deliveredEnv } = + await createMultiHumanConversation('msg-recipient-stale-read'); + + const firstMessageRes = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + conversationId, + content: [{ type: 'text', text: 'Message A' }], + }), + }, + deliveredEnv + ); + expect(firstMessageRes.status).toBe(201); + const { messageId: firstMessageId } = await firstMessageRes.json<{ messageId: string }>(); + + await new Promise(resolve => setTimeout(resolve, 2)); + + const secondMessageRes = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + conversationId, + content: [{ type: 'text', text: 'Message B' }], + }), + }, + deliveredEnv + ); + expect(secondMessageRes.status).toBe(201); + + const markReadRes = await recipientApp.request( + `/v1/conversations/${conversationId}/mark-read`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ lastSeenMessageId: firstMessageId }), + }, + deliveredEnv + ); + expect(markReadRes.status).toBe(204); + + const recipientMemberStub = env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(recipientId)); + const after = await recipientMemberStub.listConversations({ sandboxId }); + const conversation = after.conversations.find(c => c.conversationId === conversationId); + if (!conversation) { + throw new Error('Expected recipient membership conversation'); + } + const lastActivityAt = conversation.lastActivityAt; + if (lastActivityAt === null) { + throw new Error('Expected recipient conversation activity'); + } + expect(conversation.lastReadAt).toBe(ulidToTimestamp(firstMessageId)); + expect(conversation.lastReadAt).toBeLessThan(lastActivityAt); + }); }); describe('auto-title on first message', () => { diff --git a/services/kilo-chat/src/do/membership-do.ts b/services/kilo-chat/src/do/membership-do.ts index 3bd84bcd2d..565eba6788 100644 --- a/services/kilo-chat/src/do/membership-do.ts +++ b/services/kilo-chat/src/do/membership-do.ts @@ -33,6 +33,11 @@ export type ListConversationsResult = { nextCursor: string | null; }; +export type MarkReadAtLeastResult = { + applied: boolean; + lastReadAt: number | null; +}; + export class MembershipDO extends DurableObject { private db; @@ -124,6 +129,29 @@ export class MembershipDO extends DurableObject { .run(); } + markReadAtLeast(conversationId: string, readAt: number): MarkReadAtLeastResult { + const row = this.db + .select({ lastReadAt: conversations.last_read_at }) + .from(conversations) + .where(eq(conversations.conversation_id, conversationId)) + .get(); + + if (!row) { + return { applied: false, lastReadAt: null }; + } + if (row.lastReadAt !== null && row.lastReadAt >= readAt) { + return { applied: false, lastReadAt: row.lastReadAt }; + } + + this.db + .update(conversations) + .set({ last_read_at: readAt }) + .where(eq(conversations.conversation_id, conversationId)) + .run(); + + return { applied: true, lastReadAt: readAt }; + } + updateLastActivityAndMarkRead(conversationId: string, at: number): void { this.db .update(conversations) diff --git a/services/kilo-chat/src/routes/conversations.ts b/services/kilo-chat/src/routes/conversations.ts index 6336202cee..82dec3ccd1 100644 --- a/services/kilo-chat/src/routes/conversations.ts +++ b/services/kilo-chat/src/routes/conversations.ts @@ -17,6 +17,7 @@ import { ulidSchema, createConversationRequestSchema, listConversationsQuerySchema, + markConversationReadRequestSchema, renameConversationRequestSchema, decodeConversationCursor, } from '@kilocode/kilo-chat'; @@ -172,10 +173,27 @@ export function registerConversationRoutes( } const conversationId = idParam.data; + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON' }, 400); + } + + const body = markConversationReadRequestSchema.safeParse(rawBody); + if (!body.success) { + return c.json({ error: 'Invalid request', issues: body.error.issues }, 400); + } + const callerId = c.get('callerId'); - const result = await markReadFor(c.env, callerId, { conversationId }, makeSchedule(c)); + const result = await markReadFor( + c.env, + callerId, + { conversationId, lastSeenMessageId: body.data.lastSeenMessageId }, + makeSchedule(c) + ); if (!result.ok) { - return c.json({ error: result.error }, 403); + return c.json({ error: result.error }, result.code === 'invalid' ? 400 : 403); } return c.body(null, 204); diff --git a/services/kilo-chat/src/services/conversations.ts b/services/kilo-chat/src/services/conversations.ts index 966c7194d0..e8f3436094 100644 --- a/services/kilo-chat/src/services/conversations.ts +++ b/services/kilo-chat/src/services/conversations.ts @@ -4,6 +4,7 @@ */ import { ulid } from 'ulid'; +import { ulidToTimestamp } from '@kilocode/kilo-chat'; import { withDORetry } from '@kilocode/worker-utils'; import { extractConversationContext, @@ -327,9 +328,12 @@ export async function leaveConversationFor( export type MarkReadParams = { conversationId: string; + lastSeenMessageId: string; }; -export type MarkReadResult = { ok: true } | { ok: false; code: 'forbidden'; error: string }; +export type MarkReadResult = + | { ok: true } + | { ok: false; code: 'forbidden' | 'invalid'; error: string }; export async function markReadFor( env: Env, @@ -337,9 +341,10 @@ export async function markReadFor( params: MarkReadParams, ctx: DeferCtx ): Promise { - const { conversationId } = params; + const { conversationId, lastSeenMessageId } = params; // Single getInfo() call for both membership check and context extraction. + const convStub = env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(conversationId)); const info = await withDORetry( () => env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(conversationId)), stub => stub.getInfo(), @@ -349,19 +354,28 @@ export async function markReadFor( return { ok: false, code: 'forbidden', error: 'Forbidden' }; } - const now = Date.now(); - await withDORetry( + const message = await withDORetry( + () => convStub, + stub => stub.getMessage(lastSeenMessageId), + 'ConversationDO.getMessage' + ); + if (!message) { + return { ok: false, code: 'invalid', error: 'Message does not belong to conversation' }; + } + + const lastReadAt = ulidToTimestamp(lastSeenMessageId); + const readResult = await withDORetry( () => env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(userId)), - stub => stub.markRead(conversationId, now), + stub => stub.markReadAtLeast(conversationId, lastReadAt), 'MembershipDO.markRead' ); const { sandboxId } = extractConversationContext(info.members); - if (sandboxId) { + if (sandboxId && readResult.applied) { const pushPromise = pushInstanceEventToUser(env, sandboxId, userId, 'conversation.read', { conversationId, memberId: userId, - lastReadAt: now, + lastReadAt, }); ctx.waitUntil(pushPromise); } From a28b13fd9dbe7d188b94f25e4b3c1c8ab39551eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 1 May 2026 23:50:30 +0200 Subject: [PATCH 070/289] fix(kilo-chat): keep first auto-title --- .../src/__tests__/messages-routes.test.ts | 95 ++++++++++++++++++- services/kilo-chat/src/do/conversation-do.ts | 16 ++-- services/kilo-chat/src/services/messages.ts | 29 +++--- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index 4a2d11b565..9f5620638d 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -1,8 +1,9 @@ import { env } from 'cloudflare:test'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ulidToTimestamp } from '@kilocode/kilo-chat'; import { ulid } from 'ulid'; import type { ConversationDO } from '../do/conversation-do'; +import { postCommitFanOut } from '../services/messages'; import { makeApp } from './helpers'; function getConvStub(convId: string): DurableObjectStub { @@ -1070,4 +1071,96 @@ describe('auto-title on first message', () => { const { messages } = await convStub.listMessages({ limit: 10 }); expect(messages).toHaveLength(1); }); + + it('keeps the first auto-title when a later fan-out also observed no title', async () => { + const userId = 'user-autotitle-race'; + const sandboxId = 'sandbox-autotitle-race'; + const botId = `bot:kiloclaw:${sandboxId}`; + const conversationId = ulid(); + const joinedAt = Date.now(); + const members: Array<{ id: string; kind: 'user' | 'bot' }> = [ + { id: userId, kind: 'user' }, + { id: botId, kind: 'bot' }, + ]; + + const convStub = getConvStub(conversationId); + const initResult = await convStub.initialize({ + id: conversationId, + title: null, + createdBy: userId, + createdAt: joinedAt, + members, + }); + expect(initResult).toEqual({ ok: true }); + + for (const member of members) { + await env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(member.id)).addConversation({ + conversationId, + title: null, + sandboxId, + joinedAt, + }); + } + + const staleInfo = { + id: conversationId, + title: null, + createdBy: userId, + createdAt: joinedAt, + members, + }; + const pushedEvents: Array<{ event: string; payload: unknown }> = []; + const pushEvent = vi.fn( + async (_userId: string, _context: string, event: string, payload: unknown) => { + pushedEvents.push({ event, payload }); + return true; + } + ); + const eventEnv = { + ...env, + EVENT_SERVICE: { + fetch: env.EVENT_SERVICE.fetch.bind(env.EVENT_SERVICE), + connect: env.EVENT_SERVICE.connect.bind(env.EVENT_SERVICE), + pushEvent, + }, + } satisfies Env; + + await postCommitFanOut( + eventEnv, + staleInfo, + userId, + conversationId, + ulid(), + [{ type: 'text', text: 'First title' }], + undefined, + undefined + ); + await postCommitFanOut( + eventEnv, + staleInfo, + userId, + conversationId, + ulid(), + [{ type: 'text', text: 'Second title' }], + undefined, + undefined + ); + + const infoAfter = await convStub.getInfo(); + expect(infoAfter!.title).toBe('First title'); + + const userMembership = await env.MEMBERSHIP_DO.get( + env.MEMBERSHIP_DO.idFromName(userId) + ).listConversations({ sandboxId }); + const botMembership = await env.MEMBERSHIP_DO.get( + env.MEMBERSHIP_DO.idFromName(botId) + ).listConversations({ sandboxId }); + expect(userMembership.conversations[0].title).toBe('First title'); + expect(botMembership.conversations[0].title).toBe('First title'); + + const renamedPayloads = pushedEvents + .filter(pushedEvent => pushedEvent.event === 'conversation.renamed') + .map(pushedEvent => pushedEvent.payload); + expect(renamedPayloads).toEqual([{ conversationId, title: 'First title' }]); + }); }); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index ee8c858abf..db32caeb40 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -661,14 +661,18 @@ export class ConversationDO extends DurableObject { } /** - * Writes the conversation title without a membership check. Used only by - * internal code paths that have already authorized the change (e.g. the - * auto-title flow after the first bot reply commits). Human-initiated - * renames must go through updateTitleIfMember. + * Writes the auto-title without a membership check only when the conversation + * is still untitled. Human-initiated renames must go through + * updateTitleIfMember. */ - updateTitleInternal(title: string): { ok: true } { + updateTitleIfNullInternal(title: string): { ok: true; applied: boolean } { + const row = this.db.select({ title: conversation.title }).from(conversation).get(); + if (!row || row.title !== null) { + return { ok: true, applied: false }; + } + this.db.update(conversation).set({ title }).run(); - return { ok: true }; + return { ok: true, applied: true }; } leaveMember(memberId: string): { diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 733a59d021..6f8573450f 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -99,7 +99,7 @@ export async function createMessageFor( return { ok: true, messageId, clientId }; } -async function postCommitFanOut( +export async function postCommitFanOut( env: Env, info: ConversationInfo, callerId: string, @@ -186,16 +186,18 @@ async function postCommitFanOut( const autoTitle = computeAutoTitle(); // Persist the auto-title on the ConversationDO in parallel with fan-out. - const conversationDoTitleWrite = async () => { - if (autoTitle === null) return; + const conversationDoTitleWrite = async (): Promise => { + if (autoTitle === null) return false; try { - await withDORetry( + const result = await withDORetry( () => env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(conversationId)), - stub => stub.updateTitleInternal(autoTitle), - 'ConversationDO.updateTitleInternal' + stub => stub.updateTitleIfNullInternal(autoTitle), + 'ConversationDO.updateTitleIfNullInternal' ); + return result.applied; } catch (err) { logger.error('Failed to auto-title conversation on ConversationDO', formatError(err)); + return false; } }; @@ -227,7 +229,12 @@ async function postCommitFanOut( // Run webhook delivery, ConversationDO title write, and event push in // parallel; membership updates run after commit side effects settle. - await Promise.all([webhookDelivery(), conversationDoTitleWrite(), pushMessageEvents()]); + const [, autoTitleApplied] = await Promise.all([ + webhookDelivery(), + conversationDoTitleWrite(), + pushMessageEvents(), + ]); + const appliedAutoTitle = autoTitleApplied ? autoTitle : null; // ── Block C: Single MembershipDO RPC per member ────────────────────── // Combines autoTitle, lastActivityAt, and lastReadAt into one round-trip. @@ -246,7 +253,7 @@ async function postCommitFanOut( stub => stub.applyPostCommit({ conversationId, - ...(autoTitle !== null && { title: autoTitle }), + ...(appliedAutoTitle !== null && { title: appliedAutoTitle }), activityAt: now, markRead: isSender, }), @@ -263,11 +270,11 @@ async function postCommitFanOut( // ── Block D: Instance-level conversation events ────────────────────── if (sandboxId) { const instanceEvents: Promise[] = []; - if (autoTitle !== null) { + if (appliedAutoTitle !== null) { instanceEvents.push( pushInstanceEvent(env, sandboxId, humanMemberIds, 'conversation.renamed', { conversationId, - title: autoTitle, + title: appliedAutoTitle, }) ); } @@ -305,7 +312,7 @@ async function postCommitFanOut( const senderUserId = isSenderHuman ? callerId : null; const bodyPreview = contentBlocksToText(content).slice(0, 200); const sandboxLabel = await fetchSandboxLabel(env.HYPERDRIVE.connectionString, sandboxId); - const conversationTitle = info.title ?? autoTitle ?? 'Untitled'; + const conversationTitle = info.title ?? appliedAutoTitle ?? 'Untitled'; await env.NOTIFICATIONS.sendPushForConversation({ conversationId, sandboxId, From a03eb0906934d1e549398dc9d50c8fd9bb0d34cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 2 May 2026 00:00:19 +0200 Subject: [PATCH 071/289] fix(kilo-chat): suppress pending message actions --- .../kilo-chat/conversation-screen.tsx | 2 + .../kilo-chat/message-actions.test.ts | 14 +++++++ .../components/kilo-chat/message-actions.ts | 12 ++++-- .../kilo-chat/components/MessageBubble.tsx | 37 +++++++++++------- .../components/message-action-availability.ts | 24 ++++++++++++ .../components/message-bubble-actions.test.ts | 39 +++++++++++++++++++ 6 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/message-action-availability.ts create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 4b7eaa8c35..ab6b83a242 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -157,11 +157,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl if (message.deleted) { return; } + const isPendingMessage = message.id.startsWith('pending-'); const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; const actionSheet = buildMessageActionSheetOptions({ isOwnMessage, canReact: currentUserId !== null, canReply: !message.deliveryFailed, + isPendingMessage, }); showActionSheetWithOptions( { diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts index c51b45da31..df7cc048d7 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -74,4 +74,18 @@ describe('buildMessageActionSheetOptions', () => { expect(selectedAction).toEqual({ kind: 'reply', label: 'Reply' }); expect(selectedAction?.kind).not.toBe('reaction'); }); + + it('offers no API-backed actions for pending messages', () => { + const actionSheet = buildMessageActionSheetOptions({ + isOwnMessage: true, + canReact: true, + canReply: true, + isPendingMessage: true, + }); + + expect(actionSheet.options).toEqual(['Cancel']); + expect(actionSheet.actions.every(action => action.kind === 'cancel')).toBe(true); + expect(actionSheet.destructiveButtonIndex).toBeUndefined(); + expect(getSelectedMessageAction(actionSheet, 0)).toBeNull(); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index 0f6184df1a..037a3259ae 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -13,12 +13,14 @@ type BuildMessageActionSheetOptionsInput = { isOwnMessage: boolean; canReact: boolean; canReply: boolean; + isPendingMessage?: boolean; }; export function buildMessageActionSheetOptions({ isOwnMessage, canReact, canReply, + isPendingMessage = false, }: BuildMessageActionSheetOptionsInput): { actions: MessageAction[]; options: string[]; @@ -26,21 +28,23 @@ export function buildMessageActionSheetOptions({ destructiveButtonIndex?: number; } { const actions: MessageAction[] = []; - if (canReact) { + const canUseApiBackedActions = !isPendingMessage; + if (canUseApiBackedActions && canReact) { for (const emoji of FIRST_REACTION_EMOJIS) { actions.push({ kind: 'reaction', label: `${emoji} React`, emoji }); } } - if (canReply) { + if (canUseApiBackedActions && canReply) { actions.push({ kind: 'reply', label: 'Reply' }); } - if (isOwnMessage) { + if (canUseApiBackedActions && isOwnMessage) { actions.push({ kind: 'edit', label: 'Edit' }, { kind: 'delete', label: 'Delete' }); } actions.push({ kind: 'cancel', label: 'Cancel' }); const options = actions.map(action => action.label); - const destructiveButtonIndex = isOwnMessage ? options.indexOf('Delete') : undefined; + const deleteButtonIndex = options.indexOf('Delete'); + const destructiveButtonIndex = deleteButtonIndex === -1 ? undefined : deleteButtonIndex; return { actions, options, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx index cf24171c9d..f83f78de8f 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx @@ -11,6 +11,7 @@ import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo import { ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; +import { buildMessageActionAvailability } from './message-action-availability'; const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) { return ( @@ -79,17 +80,20 @@ export const MessageBubble = memo(function MessageBubble({ }); const textContent = message.deleted ? '' : contentBlocksToText(message.content); + const actionAvailability = buildMessageActionAvailability(message, isOwn); const myReactions = new Set( message.reactions.filter(r => r.memberIds.includes(currentUserId)).map(r => r.emoji) ); function handleStartEdit() { + if (!actionAvailability.canEdit) return; setEditText(textContent); setIsEditing(true); } function handleSaveEdit() { + if (!actionAvailability.canEdit) return; const trimmed = editText.trim(); if (!trimmed) return; // Short-circuit no-op edits so we don't bump updatedAt and flash the @@ -109,6 +113,7 @@ export const MessageBubble = memo(function MessageBubble({ function handleQuickPickSelect(emoji: string) { setShowQuickPick(false); + if (!actionAvailability.canReact) return; if (myReactions.has(emoji)) { onRemoveReaction(message.id, emoji); } else { @@ -119,6 +124,7 @@ export const MessageBubble = memo(function MessageBubble({ function handleFullPickerSelect(emoji: string) { setShowFullPicker(false); setShowQuickPick(false); + if (!actionAvailability.canReact) return; if (myReactions.has(emoji)) { onRemoveReaction(message.id, emoji); } else { @@ -134,13 +140,15 @@ export const MessageBubble = memo(function MessageBubble({ isOwn ? 'right-full mr-1' : 'left-full ml-1' }`} > - + {actionAvailability.canReact && ( + + )} - {isOwn && !message.deliveryFailed && ( + {actionAvailability.canEdit && ( )} - {isOwn && ( + {actionAvailability.canDelete && ( )} - {!message.deliveryFailed && ( + {actionAvailability.canReply && ( diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx index 0573458a50..d9bca111d1 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/ReactionPills.tsx @@ -6,7 +6,7 @@ import { EmojiPicker } from './EmojiPicker'; type ReactionPillsProps = { reactions: ReactionSummary[]; - currentUserId: string; + currentUserId: string | null; isOwn: boolean; onAdd: (emoji: string) => void; onRemove: (emoji: string) => void; @@ -26,7 +26,7 @@ export function ReactionPills({ (emoji: string) => { setShowPicker(false); const existing = reactions.find(r => r.emoji === emoji); - if (existing?.memberIds.includes(currentUserId)) { + if (currentUserId !== null && existing?.memberIds.includes(currentUserId)) { onRemove(emoji); } else { onAdd(emoji); @@ -40,7 +40,7 @@ export function ReactionPills({ return (
{reactions.map(r => { - const isMine = r.memberIds.includes(currentUserId); + const isMine = currentUserId !== null && r.memberIds.includes(currentUserId); return (
)} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts index b96f69c608..0c06d322f6 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts @@ -7,6 +7,7 @@ const baseMessage = { senderId: 'user-1', content: [{ type: 'text', text: 'hello' }], inReplyToMessageId: null, + replyTo: null, updatedAt: null, clientUpdatedAt: null, deleted: false, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts index 88bbf70ad1..e957c96de1 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts @@ -10,6 +10,7 @@ describe('applyMessageCreatedEventToPages', () => { senderId: '', content: [{ type: 'text', text: 'draft' }], inReplyToMessageId: null, + replyTo: null, updatedAt: null, clientUpdatedAt: null, deleted: false, @@ -39,6 +40,7 @@ describe('applyMessageCreatedEventToPages', () => { senderId: 'user-1', content: [{ type: 'text', text: 'server text' }], inReplyToMessageId: '01K8ZB8B3H9BRWZ6KCN39AX09H', + replyTo: null, updatedAt: null, clientUpdatedAt: null, deleted: false, diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 679cd9b767..a02f6c8f97 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -199,6 +199,7 @@ export function messageFromCreatedEvent(e: MessageCreatedEvent): Message { senderId: e.senderId, content: e.content, inReplyToMessageId: e.inReplyToMessageId, + replyTo: null, updatedAt: null, clientUpdatedAt: null, deleted: false, @@ -249,6 +250,7 @@ export function useSendMessage( senderId: currentUserId, content: variables.content, inReplyToMessageId: variables.inReplyToMessageId ?? null, + replyTo: null, updatedAt: null, clientUpdatedAt: null, deleted: false, diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index fd85b79774..1070884239 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -111,11 +111,19 @@ export const reactionSummarySchema = z.object({ // ── Messages ──────────────────────────────────────────────────────── +export const replyToMessageSnapshotSchema = z.object({ + messageId: z.string(), + senderId: z.string().nullable(), + deleted: z.boolean(), + previewText: z.string().nullable(), +}); + export const messageSchema = z.object({ id: z.string(), senderId: z.string(), content: z.array(contentBlockSchema), inReplyToMessageId: z.string().nullable(), + replyTo: replyToMessageSnapshotSchema.nullable(), updatedAt: z.number().nullable(), clientUpdatedAt: z.number().nullable(), deleted: z.boolean(), diff --git a/packages/kilo-chat/src/types.ts b/packages/kilo-chat/src/types.ts index 85fa4947cd..e488739567 100644 --- a/packages/kilo-chat/src/types.ts +++ b/packages/kilo-chat/src/types.ts @@ -6,6 +6,7 @@ import type { textBlockSchema, contentBlockSchema, reactionSummarySchema, + replyToMessageSnapshotSchema, messageSchema, conversationListItemSchema, conversationDetailSchema, @@ -83,6 +84,7 @@ export type BotConversationSummary = z.infer; +export type ReplyToMessageSnapshot = z.infer; // ── Events ────────────────────────────────────────────────────────── export type MessageCreatedEvent = z.infer; diff --git a/services/kilo-chat/src/__tests__/conversation-do.test.ts b/services/kilo-chat/src/__tests__/conversation-do.test.ts index e5ed8c401b..c6efe04673 100644 --- a/services/kilo-chat/src/__tests__/conversation-do.test.ts +++ b/services/kilo-chat/src/__tests__/conversation-do.test.ts @@ -122,6 +122,36 @@ describe('ConversationDO', () => { expect(page2).toHaveLength(0); }); + it('listMessages - includes a reply snapshot when parent is outside the page', async () => { + const stub = getStub('conv-list-reply-snapshot'); + await stub.initialize(BASE_PARAMS); + const parent = await stub.createMessage({ + senderId: 'user-alice', + content: [{ type: 'text', text: 'Parent context' }], + }); + expect(parent.ok).toBe(true); + if (!parent.ok) return; + + const reply = await stub.createMessage({ + senderId: 'bot-1', + content: [{ type: 'text', text: 'Reply body' }], + inReplyToMessageId: parent.messageId, + }); + expect(reply.ok).toBe(true); + if (!reply.ok) return; + + const { messages } = await stub.listMessages({ limit: 1 }); + + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe(reply.messageId); + expect(messages[0].replyTo).toEqual({ + messageId: parent.messageId, + senderId: 'user-alice', + deleted: false, + previewText: 'Parent context', + }); + }); + it('editMessage - edits message with newer timestamp', async () => { const stub = getStub('conv-edit-1'); await stub.initialize(BASE_PARAMS); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index e46ddd5708..0b08536d34 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -1,9 +1,11 @@ -import type { - ContentBlock, - ActionsBlock, - Message, - ReactionSummary, - ExecApprovalDecision, +import { + contentBlocksToText, + type ContentBlock, + type ActionsBlock, + type Message, + type ReactionSummary, + type ExecApprovalDecision, + type ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; import { DurableObject } from 'cloudflare:workers'; import { logger } from '../util/logger'; @@ -37,6 +39,31 @@ import { conversation, members, messages, reactions } from '../db/conversation-s import migrations from '../../drizzle/conversation/migrations'; import { monotonicFactory } from 'ulid'; +const REPLY_PREVIEW_MAX_CHARS = 160; + +type StoredMessageRow = typeof messages.$inferSelect; + +function buildReplySnapshot( + messageId: string, + parent: StoredMessageRow | undefined +): ReplyToMessageSnapshot { + if (!parent) { + return { messageId, senderId: null, deleted: true, previewText: null }; + } + + if (parent.deleted === 1) { + return { messageId, senderId: parent.sender_id, deleted: true, previewText: null }; + } + + const preview = contentBlocksToText(parseStoredContent(parent.content, parent.id)).trim(); + return { + messageId, + senderId: parent.sender_id, + deleted: false, + previewText: (preview || 'Message').slice(0, REPLY_PREVIEW_MAX_CHARS), + }; +} + export type MemberContext = { humanMemberIds: string[]; sandboxId: string | null; @@ -370,6 +397,15 @@ export class ConversationDO extends DurableObject { } const ids = rows.map(r => r.id); + const replyParentIds = [ + ...new Set(rows.flatMap(r => (r.in_reply_to_message_id ? [r.in_reply_to_message_id] : []))), + ]; + const replyParentRows = + replyParentIds.length > 0 + ? this.db.select().from(messages).where(inArray(messages.id, replyParentIds)).all() + : []; + const replyParentById = new Map(replyParentRows.map(row => [row.id, row])); + const reactionRows = this.db .select() .from(reactions) @@ -395,6 +431,12 @@ export class ConversationDO extends DurableObject { senderId: row.sender_id, content: row.deleted === 1 ? [] : parseStoredContent(row.content, row.id), inReplyToMessageId: row.in_reply_to_message_id, + replyTo: row.in_reply_to_message_id + ? buildReplySnapshot( + row.in_reply_to_message_id, + replyParentById.get(row.in_reply_to_message_id) + ) + : null, updatedAt: row.updated_at, clientUpdatedAt: row.client_updated_at, deleted: row.deleted === 1, From 0b91d2979dc067cccd535eae19c830f5a701697c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 2 May 2026 01:22:58 +0200 Subject: [PATCH 086/289] fix(kilo-chat): preserve live reply snapshots --- .../kilo-chat/live-message-cache.test.ts | 27 +++++++ .../hooks/use-messages-cache.test.ts | 1 + packages/kilo-chat-hooks/src/use-messages.ts | 2 +- packages/kilo-chat/src/events.ts | 2 + packages/kilo-chat/src/index.ts | 1 + packages/kilo-chat/src/utils.ts | 30 ++++++++ .../src/__tests__/messages-routes.test.ts | 75 +++++++++++++++++++ services/kilo-chat/src/do/conversation-do.ts | 30 +++----- services/kilo-chat/src/services/messages.ts | 35 ++++++++- 9 files changed, 182 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts index bd5c0f02cb..2c11f28705 100644 --- a/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts +++ b/apps/mobile/src/components/kilo-chat/live-message-cache.test.ts @@ -37,6 +37,7 @@ describe('applyMessageCreatedEventToPages', () => { senderId: 'bot:sandbox-1', content: [{ type: 'text', text: 'hello from bot' }], inReplyToMessageId: null, + replyTo: null, clientId: null, } satisfies MessageCreatedEvent; @@ -45,6 +46,31 @@ describe('applyMessageCreatedEventToPages', () => { expect(result.pages[0]?.map(m => m.id)).toEqual(['bot-message', 'existing']); }); + it('preserves reply snapshots from created events when the parent is not loaded', () => { + const data: InfiniteData = { + pages: [[message('existing')]], + pageParams: [undefined], + }; + const replyTo = { + messageId: 'parent-outside-loaded-pages', + senderId: 'user:parent', + deleted: false, + previewText: 'Parent context from an older page', + }; + const event = { + messageId: 'reply-message', + senderId: 'bot:sandbox-1', + content: [{ type: 'text', text: 'reply body' }], + inReplyToMessageId: replyTo.messageId, + replyTo, + clientId: null, + } satisfies MessageCreatedEvent; + + const result = applyMessageCreatedEventToPages(data, event); + + expect(result.pages[0]?.[0]?.replyTo).toEqual(replyTo); + }); + it('repositions resolved optimistic messages by newest server id', () => { const remoteOlder = message('01HX0000000000000000000000'); const pendingLocal = message('pending-client-1'); @@ -57,6 +83,7 @@ describe('applyMessageCreatedEventToPages', () => { senderId: 'user:1', content: [{ type: 'text', text: 'local newer' }], inReplyToMessageId: null, + replyTo: null, clientId: 'client-1', } satisfies MessageCreatedEvent; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts index e957c96de1..49d41ac02c 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/use-messages-cache.test.ts @@ -26,6 +26,7 @@ describe('applyMessageCreatedEventToPages', () => { senderId: 'user-1', content: [{ type: 'text', text: 'server text' }], inReplyToMessageId: '01K8ZB8B3H9BRWZ6KCN39AX09H', + replyTo: null, clientId: 'client-1', } satisfies MessageCreatedEvent; diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index a02f6c8f97..1f55cb10fa 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -199,7 +199,7 @@ export function messageFromCreatedEvent(e: MessageCreatedEvent): Message { senderId: e.senderId, content: e.content, inReplyToMessageId: e.inReplyToMessageId, - replyTo: null, + replyTo: e.replyTo, updatedAt: null, clientUpdatedAt: null, deleted: false, diff --git a/packages/kilo-chat/src/events.ts b/packages/kilo-chat/src/events.ts index 6a1d0e4522..7ba5322c2f 100644 --- a/packages/kilo-chat/src/events.ts +++ b/packages/kilo-chat/src/events.ts @@ -7,6 +7,7 @@ import { execApprovalDecisionSchema, nonEmptyStringSchema, nonNegativeIntegerSchema, + replyToMessageSnapshotSchema, sandboxIdSchema, ulidSchema, } from './schemas'; @@ -18,6 +19,7 @@ export const messageCreatedEventSchema = z.object({ senderId: nonEmptyStringSchema, content: z.array(contentBlockSchema), inReplyToMessageId: ulidSchema.nullable(), + replyTo: replyToMessageSnapshotSchema.nullable(), clientId: ulidSchema.nullable(), }); diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index a125d5f26a..8f6b3a7064 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -3,6 +3,7 @@ export { KiloChatApiError, formatKiloChatError } from './errors'; export { ulidToTimestamp, contentBlocksToText, + buildReplyToMessageSnapshot, encodeConversationCursor, decodeConversationCursor, type ConversationCursor, diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts index 2210353c9b..d8c66ffa06 100644 --- a/packages/kilo-chat/src/utils.ts +++ b/packages/kilo-chat/src/utils.ts @@ -2,6 +2,7 @@ import { decodeTime } from 'ulid'; import type { z } from 'zod'; import { conversationCursorSchema } from './schemas'; +import type { ReplyToMessageSnapshot } from './types'; /** Extract the millisecond timestamp encoded in a ULID. */ export function ulidToTimestamp(ulid: string): number { @@ -65,3 +66,32 @@ export function contentBlocksToText(content: Array<{ type: string; text?: string .map(b => b.text) .join(''); } + +const REPLY_PREVIEW_MAX_CHARS = 160; + +type ReplySnapshotParent = { + senderId: string; + deleted: boolean; + content: Array<{ type: string; text?: string }>; +}; + +export function buildReplyToMessageSnapshot( + messageId: string, + parent: ReplySnapshotParent | null | undefined +): ReplyToMessageSnapshot { + if (!parent) { + return { messageId, senderId: null, deleted: true, previewText: null }; + } + + if (parent.deleted) { + return { messageId, senderId: parent.senderId, deleted: true, previewText: null }; + } + + const preview = contentBlocksToText(parent.content).trim(); + return { + messageId, + senderId: parent.senderId, + deleted: false, + previewText: (preview || 'Message').slice(0, REPLY_PREVIEW_MAX_CHARS), + }; +} diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index e95274ab13..d3e7977071 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -1303,4 +1303,79 @@ describe('auto-title on first message', () => { .map(pushedEvent => pushedEvent.payload); expect(renamedPayloads).toEqual([{ conversationId, title: 'First title' }]); }); + + it('publishes reply snapshots on message.created events', async () => { + const { conversationId, userId } = await createMultiHumanConversation('reply-event-snapshot'); + const convStub = getConvStub(conversationId); + const info = await convStub.getInfo(); + expect(info).not.toBeNull(); + if (!info) return; + + const parent = await convStub.createMessage({ + senderId: 'recipient-reply-event-snapshot', + content: [{ type: 'text', text: 'Parent context' }], + }); + expect(parent.ok).toBe(true); + if (!parent.ok) return; + + const replyMessageId = ulid(); + const pushedEvents: Array<{ event: string; payload: unknown }> = []; + const pushEvent = vi.fn( + async (_userId: string, _context: string, event: string, payload: unknown) => { + pushedEvents.push({ event, payload }); + return true; + } + ); + const eventEnv = { + ...env, + EVENT_SERVICE: { + fetch: env.EVENT_SERVICE.fetch.bind(env.EVENT_SERVICE), + connect: env.EVENT_SERVICE.connect.bind(env.EVENT_SERVICE), + pushEvent, + }, + } satisfies Env; + + await postCommitFanOut( + eventEnv, + info, + userId, + conversationId, + replyMessageId, + [{ type: 'text', text: 'Reply body' }], + parent.messageId, + undefined + ); + + const createdPayloads = pushedEvents + .filter(pushedEvent => pushedEvent.event === 'message.created') + .map(pushedEvent => pushedEvent.payload); + expect(createdPayloads).toEqual([ + { + messageId: replyMessageId, + senderId: userId, + content: [{ type: 'text', text: 'Reply body' }], + inReplyToMessageId: parent.messageId, + replyTo: { + messageId: parent.messageId, + senderId: 'recipient-reply-event-snapshot', + deleted: false, + previewText: 'Parent context', + }, + clientId: null, + }, + { + messageId: replyMessageId, + senderId: userId, + content: [{ type: 'text', text: 'Reply body' }], + inReplyToMessageId: parent.messageId, + replyTo: { + messageId: parent.messageId, + senderId: 'recipient-reply-event-snapshot', + deleted: false, + previewText: 'Parent context', + }, + clientId: null, + }, + ]); + }); }); diff --git a/services/kilo-chat/src/do/conversation-do.ts b/services/kilo-chat/src/do/conversation-do.ts index 0b08536d34..ad096a2d4b 100644 --- a/services/kilo-chat/src/do/conversation-do.ts +++ b/services/kilo-chat/src/do/conversation-do.ts @@ -1,11 +1,10 @@ import { - contentBlocksToText, + buildReplyToMessageSnapshot, type ContentBlock, type ActionsBlock, type Message, type ReactionSummary, type ExecApprovalDecision, - type ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; import { DurableObject } from 'cloudflare:workers'; import { logger } from '../util/logger'; @@ -39,29 +38,22 @@ import { conversation, members, messages, reactions } from '../db/conversation-s import migrations from '../../drizzle/conversation/migrations'; import { monotonicFactory } from 'ulid'; -const REPLY_PREVIEW_MAX_CHARS = 160; - type StoredMessageRow = typeof messages.$inferSelect; function buildReplySnapshot( messageId: string, parent: StoredMessageRow | undefined -): ReplyToMessageSnapshot { - if (!parent) { - return { messageId, senderId: null, deleted: true, previewText: null }; - } - - if (parent.deleted === 1) { - return { messageId, senderId: parent.sender_id, deleted: true, previewText: null }; - } - - const preview = contentBlocksToText(parseStoredContent(parent.content, parent.id)).trim(); - return { +): Message['replyTo'] { + return buildReplyToMessageSnapshot( messageId, - senderId: parent.sender_id, - deleted: false, - previewText: (preview || 'Message').slice(0, REPLY_PREVIEW_MAX_CHARS), - }; + parent + ? { + senderId: parent.sender_id, + deleted: parent.deleted === 1, + content: parent.deleted === 1 ? [] : parseStoredContent(parent.content, parent.id), + } + : null + ); } export type MemberContext = { diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 2d178ee501..08861ccb3d 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -7,7 +7,13 @@ * enqueue, and MembershipDO maintenance in one place. */ -import { ulidToTimestamp, type ContentBlock, type ExecApprovalDecision } from '@kilocode/kilo-chat'; +import { + buildReplyToMessageSnapshot, + ulidToTimestamp, + type ContentBlock, + type ExecApprovalDecision, + type ReplyToMessageSnapshot, +} from '@kilocode/kilo-chat'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; import { contentBlocksToText } from '../util/content'; @@ -44,6 +50,31 @@ function truncateByGrapheme(text: string, maxGraphemes: number): string { return text; } +async function getReplyToSnapshot( + env: Env, + conversationId: string, + inReplyToMessageId: string | undefined +): Promise { + if (!inReplyToMessageId) return null; + + const parent = await withDORetry( + () => env.CONVERSATION_DO.get(env.CONVERSATION_DO.idFromName(conversationId)), + stub => stub.getMessage(inReplyToMessageId), + 'ConversationDO.getMessage' + ); + + return buildReplyToMessageSnapshot( + inReplyToMessageId, + parent + ? { + senderId: parent.senderId, + deleted: parent.deleted, + content: parent.content, + } + : null + ); +} + // ─── createMessage ────────────────────────────────────────────────────────── export type CreateMessageParams = { @@ -204,6 +235,7 @@ export async function postCommitFanOut( // ── Block B: Push message.created + typing.stop ────────────────────── const pushMessageEvents = async (): Promise => { if (!sandboxId) return; + const replyTo = await getReplyToSnapshot(env, conversationId, inReplyToMessageId); await pushEventToHumanMembers( env, @@ -216,6 +248,7 @@ export async function postCommitFanOut( senderId: callerId, content, inReplyToMessageId: inReplyToMessageId ?? null, + replyTo, clientId: clientId ?? null, } ); From 87b20274a8ceb405a60166bf86302a44ae1d6a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 2 May 2026 01:34:00 +0200 Subject: [PATCH 087/289] fix(kilo-chat): preserve failed edit drafts --- .../kilo-chat/conversation-screen.tsx | 5 +- .../kilo-chat/message-input-state.test.ts | 62 +++++++++++++++++++ .../kilo-chat/message-input-state.ts | 25 ++++++-- .../components/kilo-chat/message-input.tsx | 15 ++++- .../claw/kilo-chat/components/MessageArea.tsx | 30 +++++---- .../kilo-chat/components/MessageBubble.tsx | 57 ++++++++++++----- .../components/message-edit-state.test.ts | 46 ++++++++++++++ .../components/message-edit-state.ts | 42 +++++++++++++ 8 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/message-edit-state.test.ts create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/message-edit-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 7e7fbcb153..5470985331 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -25,6 +25,7 @@ import { resolveMobileMessageInputAvailability } from './bot-send-state'; import { executeActionWithMobileFeedback } from './execute-action-feedback'; import { buildMessageActionSheetOptions, getSelectedMessageAction } from './message-actions'; import { MessageInput } from './message-input'; +import { type MessageInputSubmitControls } from './message-input-state'; import { MessageList } from './message-list'; import { buildSendMessageVariables, createSendMessageClientId } from './message-presentation'; import { useConversationPresence } from './hooks/use-conversation-presence'; @@ -104,7 +105,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl editing: editingMessage !== null, }); const handleSend = useCallback( - (text: string, inReplyToMessageId?: string) => { + (text: string, inReplyToMessageId?: string, controls?: MessageInputSubmitControls) => { if (!editingMessage && inputAvailability.disabled) { return; } @@ -118,6 +119,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, { onSuccess: () => { + controls?.clearDraft(); setEditingMessage(null); }, onError: err => { @@ -326,6 +328,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl disabled={inputAvailability.disabled} disabledReason={inputAvailability.disabledReason} initialText={editingText} + clearOnSubmit={editingMessage === null} replyingTo={replyingTo} onCancelReply={ replyingTo diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts index 694a7cdd56..8dffe4b48d 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts @@ -78,4 +78,66 @@ describe('message input typing behavior', () => { expect(valueRef.current).toBe(overLimitText); expect(canSendValues).toEqual([false]); }); + + it('leaves edit drafts intact when the caller controls clearing', () => { + const valueRef = { current: ' edited draft ' }; + const canSendValues: boolean[] = []; + const sentMessages: string[] = []; + let cleared = false; + + const submitted = submitMessageInputDraft({ + valueRef, + onSend: text => { + sentMessages.push(text); + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + clearOnSubmit: false, + }); + + expect(submitted).toBe(true); + expect(sentMessages).toEqual(['edited draft']); + expect(cleared).toBe(false); + expect(valueRef.current).toBe(' edited draft '); + expect(canSendValues).toEqual([]); + }); + + it('lets edit callers clear drafts after successful mutation', () => { + const valueRef = { current: ' edited draft ' }; + const canSendValues: boolean[] = []; + const successControls: { clearDraft?: () => void } = {}; + let cleared = false; + + const submitted = submitMessageInputDraft({ + valueRef, + onSend: (_text, _replyTo, controls) => { + if (!controls) { + throw new Error('expected submit controls'); + } + successControls.clearDraft = controls.clearDraft; + }, + clearInput: () => { + cleared = true; + }, + setCanSend: canSend => { + canSendValues.push(canSend); + }, + clearOnSubmit: false, + }); + + const clearDraft = successControls.clearDraft; + if (!clearDraft) { + throw new Error('expected submit controls'); + } + clearDraft(); + + expect(submitted).toBe(true); + expect(cleared).toBe(true); + expect(valueRef.current).toBe(''); + expect(canSendValues).toEqual([false]); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.ts b/apps/mobile/src/components/kilo-chat/message-input-state.ts index 2af55f388e..905cfebe3e 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-state.ts @@ -2,6 +2,10 @@ import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; type DraftRef = { current: string }; +export type MessageInputSubmitControls = { + clearDraft: () => void; +}; + function canSubmitDraft(text: string): boolean { return text.trim().length > 0 && text.length <= MESSAGE_TEXT_MAX_CHARS; } @@ -28,12 +32,18 @@ export function submitMessageInputDraft({ onSend, clearInput, setCanSend, + clearOnSubmit = true, }: { valueRef: DraftRef; replyingToMessageId?: string; - onSend: (text: string, inReplyToMessageId?: string) => void; + onSend: ( + text: string, + inReplyToMessageId?: string, + controls?: MessageInputSubmitControls + ) => void; clearInput: () => void; setCanSend: (canSend: boolean) => void; + clearOnSubmit?: boolean; }) { const draft = valueRef.current; if (!canSubmitDraft(draft)) { @@ -41,9 +51,14 @@ export function submitMessageInputDraft({ } const text = draft.trim(); - onSend(text, replyingToMessageId); - valueRef.current = ''; - clearInput(); - setCanSend(false); + const clearDraft = () => { + valueRef.current = ''; + clearInput(); + setCanSend(false); + }; + onSend(text, replyingToMessageId, { clearDraft }); + if (clearOnSubmit) { + clearDraft(); + } return true; } diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 23876fa6b4..6647bac24d 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -6,11 +6,19 @@ import { type Message } from '@kilocode/kilo-chat'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { applyMessageInputTextChange, submitMessageInputDraft } from './message-input-state'; +import { + applyMessageInputTextChange, + type MessageInputSubmitControls, + submitMessageInputDraft, +} from './message-input-state'; import { getReplyPreviewText } from './message-presentation'; type Props = { - onSend: (text: string, inReplyToMessageId?: string) => void; + onSend: ( + text: string, + inReplyToMessageId?: string, + controls?: MessageInputSubmitControls + ) => void; onTyping?: () => void; disabled?: boolean; initialText?: string; @@ -18,6 +26,7 @@ type Props = { replyingTo?: Message | null; onCancelReply?: () => void; disabledReason?: string | null; + clearOnSubmit?: boolean; }; export function MessageInput({ @@ -29,6 +38,7 @@ export function MessageInput({ replyingTo, onCancelReply, disabledReason, + clearOnSubmit, }: Props) { const colors = useThemeColors(); const valueRef = useRef(initialText); @@ -45,6 +55,7 @@ export function MessageInput({ onSend, clearInput: () => inputRef.current?.clear(), setCanSend, + clearOnSubmit, }); }; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index febbfdd0eb..2ddbd55ae0 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -282,21 +282,25 @@ export function MessageArea({ conversationId }: MessageAreaProps) { ); const handleEdit = useCallback( - (messageId: string, content: ContentBlock[]) => { - editMessage.mutate( - { messageId, conversationId, content: toEditableContent(content), timestamp: Date.now() }, - { - onError: err => { - if (err instanceof KiloChatApiError && err.status === 409) { - toast.error('Message was edited by someone else — please try again'); - return; - } - toast.error(formatKiloChatError(err, 'Failed to edit message')); - }, + async (messageId: string, content: ContentBlock[]): Promise => { + try { + await editMessage.mutateAsync({ + messageId, + conversationId, + content: toEditableContent(content), + timestamp: Date.now(), + }); + return true; + } catch (err) { + if (err instanceof KiloChatApiError && err.status === 409) { + toast.error('Message was edited by someone else — please try again'); + return false; } - ); + toast.error(formatKiloChatError(err, 'Failed to edit message')); + return false; + } }, - [editMessage.mutate, conversationId] + [editMessage.mutateAsync, conversationId] ); const handleDelete = useCallback((messageId: string) => { diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx index 52ded2e7a2..80aa4d64e2 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx @@ -13,10 +13,13 @@ import type { ExecApprovalDecision, ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; -import { ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat'; +import { MESSAGE_TEXT_MAX_CHARS, ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; import { buildMessageActionAvailability } from './message-action-availability'; +import { isMessageEditOverLimit, submitMessageEdit } from './message-edit-state'; + +const EDIT_COUNTER_SHOW_AT = Math.floor(MESSAGE_TEXT_MAX_CHARS * 0.8); const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) { return ( @@ -40,7 +43,7 @@ type MessageBubbleProps = { isOwn: boolean; replyToMessage?: Message | ReplyToMessageSnapshot | null; pendingDeleteId: string | null; - onEdit: (messageId: string, content: ContentBlock[]) => void; + onEdit: (messageId: string, content: ContentBlock[]) => Promise; onDelete: (messageId: string) => void; onConfirmDelete: (messageId: string) => void; onCancelDelete: () => void; @@ -79,6 +82,7 @@ export const MessageBubble = memo(function MessageBubble({ const { assistantName } = useKiloChatContext(); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(''); + const [isSavingEdit, setIsSavingEdit] = useState(false); const [showActions, setShowActions] = useState(false); const [showQuickPick, setShowQuickPick] = useState(false); const [showFullPicker, setShowFullPicker] = useState(false); @@ -93,6 +97,8 @@ export const MessageBubble = memo(function MessageBubble({ }); const textContent = message.deleted ? '' : contentBlocksToText(message.content); + const editOverLimit = isMessageEditOverLimit(editText); + const showEditCounter = editText.length >= EDIT_COUNTER_SHOW_AT || editOverLimit; const baseActionAvailability = buildMessageActionAvailability(message, isOwn); const actionAvailability = currentUserId === null @@ -117,23 +123,32 @@ export const MessageBubble = memo(function MessageBubble({ setIsEditing(true); } - function handleSaveEdit() { - if (!actionAvailability.canEdit) return; - const trimmed = editText.trim(); - if (!trimmed) return; - // Short-circuit no-op edits so we don't bump updatedAt and flash the - // "(edited)" label when the user presses Enter without changes. - if (trimmed === textContent.trim()) { - setIsEditing(false); - return; + const canSaveEdit = + actionAvailability.canEdit && !isSavingEdit && editText.trim().length > 0 && !editOverLimit; + + async function handleSaveEdit() { + if (!canSaveEdit) return; + setIsSavingEdit(true); + try { + await submitMessageEdit({ + messageId: message.id, + editText, + originalText: textContent, + onEdit, + closeEditor: () => { + setIsEditing(false); + setEditText(''); + }, + }); + } finally { + setIsSavingEdit(false); } - onEdit(message.id, [{ type: 'text', text: trimmed }]); - setIsEditing(false); } function handleCancelEdit() { setIsEditing(false); setEditText(''); + setIsSavingEdit(false); } function handleQuickPickSelect(emoji: string) { @@ -284,16 +299,26 @@ export const MessageBubble = memo(function MessageBubble({ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSaveEdit(); + if (canSaveEdit) void handleSaveEdit(); } if (e.key === 'Escape') handleCancelEdit(); }} autoFocus /> +
+ {editText.length.toLocaleString('en-US')} /{' '} + {MESSAGE_TEXT_MAX_CHARS.toLocaleString('en-US')} +
+
+ + ); +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts index b152e8d535..00f262fded 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts @@ -13,6 +13,9 @@ export type KiloChatContextValue = { basePath: string; noInstanceRedirect: string; isInstanceLoading: boolean; + isInstanceError: boolean; + instanceErrorMessage: string | null; + onRetryInstanceStatus: () => void; eventService: EventServiceClient; kiloChatClient: KiloChatClient; }; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx index 509c2f39e4..7d547ad659 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx @@ -6,7 +6,9 @@ import { KiloChatLayout } from './components/KiloChatLayout'; export default function KiloChatRootLayout({ children }: { children: React.ReactNode }) { const { data: user } = useUser(); - const { data: status, isLoading } = useKiloClawStatus(); + const { data: status, error, isError, isLoading, refetch } = useKiloClawStatus(); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; return ( void refetch()} assistantName={status?.botName ?? null} > {children} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/page.tsx b/apps/web/src/app/(app)/claw/kilo-chat/page.tsx index fcd65860c8..388e43c36e 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/page.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/page.tsx @@ -4,16 +4,34 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { MessagesSquare } from 'lucide-react'; import { useKiloChatContext } from './components/kiloChatContext'; +import { KiloChatStatusError } from './components/KiloChatStatusError'; +import { kiloChatInstanceRouteDecision } from './[conversationId]/conversation-route-guard'; export default function KiloChatIndexPage() { const router = useRouter(); - const { instanceStatus, isInstanceLoading, noInstanceRedirect } = useKiloChatContext(); + const { + instanceErrorMessage, + instanceStatus, + isInstanceError, + isInstanceLoading, + noInstanceRedirect, + onRetryInstanceStatus, + } = useKiloChatContext(); + const routeDecision = kiloChatInstanceRouteDecision({ + instanceStatus, + isInstanceError, + isInstanceLoading, + }); useEffect(() => { - if (!isInstanceLoading && !instanceStatus) { + if (routeDecision === 'redirect-no-instance') { router.replace(noInstanceRedirect); } - }, [isInstanceLoading, instanceStatus, noInstanceRedirect, router]); + }, [noInstanceRedirect, routeDecision, router]); + + if (routeDecision === 'status-error') { + return ; + } return (
diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx index 30f2e85160..2233cf0be1 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx @@ -9,7 +9,9 @@ export default function OrgKiloChatRootLayout({ children }: { children: React.Re const params = useParams<{ id: string }>(); const organizationId = params.id; const { data: user } = useUser(); - const { data: status, isLoading } = useOrgKiloClawStatus(organizationId); + const { data: status, error, isError, isLoading, refetch } = useOrgKiloClawStatus(organizationId); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; const basePath = `/organizations/${organizationId}/claw/kilo-chat`; const noInstanceRedirect = `/organizations/${organizationId}/claw/new`; @@ -22,6 +24,9 @@ export default function OrgKiloChatRootLayout({ children }: { children: React.Re noInstanceRedirect={noInstanceRedirect} instanceStatus={status?.status ?? null} isInstanceLoading={isLoading} + isInstanceError={isError} + instanceErrorMessage={instanceErrorMessage} + onRetryInstanceStatus={() => void refetch()} assistantName={status?.botName ?? null} > {children} From f90e2b9f622c44306d673b77699dc6d806a5bfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 00:35:19 +0200 Subject: [PATCH 179/289] fix(web): preserve pending chat drafts --- .../kilo-chat/components/MessageInput.tsx | 31 ++++++++++--- .../components/message-input-state.test.ts | 44 +++++++++++++++++-- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageInput.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageInput.tsx index 58cf156365..de766a0eca 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageInput.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageInput.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { Send } from 'lucide-react'; import type { Message } from '@kilocode/kilo-chat'; import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; @@ -34,12 +34,22 @@ type MessageInputSubmissionState = { replyingTo: Message | null; }; +function sameReplyTarget(left: Message | null, right: Message | null): boolean { + return (left?.id ?? null) === (right?.id ?? null); +} + export function nextMessageInputStateAfterSend( - state: MessageInputSubmissionState, + currentState: MessageInputSubmissionState, + submittedState: MessageInputSubmissionState, sendSucceeded: boolean ): MessageInputSubmissionState { - if (!sendSucceeded) return state; - return { text: '', replyingTo: null }; + if (!sendSucceeded) return currentState; + return { + text: currentState.text === submittedState.text ? '' : currentState.text, + replyingTo: sameReplyTarget(currentState.replyingTo, submittedState.replyingTo) + ? null + : currentState.replyingTo, + }; } export function MessageInput({ @@ -55,11 +65,16 @@ export function MessageInput({ const [text, setText] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const textareaRef = useRef(null); + const latestStateRef = useRef({ text: '', replyingTo: null }); useEffect(() => { if (replyingTo) textareaRef.current?.focus(); }, [replyingTo]); + useLayoutEffect(() => { + latestStateRef.current = { text, replyingTo }; + }, [text, replyingTo]); + useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -77,12 +92,15 @@ export function MessageInput({ if (isSubmitting) return; if (!canSubmitMessageInput(currentUserId, canSend, overLimit, text)) return; const trimmed = text.trim(); + const submittedState = { text, replyingTo }; setIsSubmitting(true); try { const sendSucceeded = await onSend(trimmed, replyingTo?.id); - const nextState = nextMessageInputStateAfterSend({ text, replyingTo }, sendSucceeded); + const currentState = latestStateRef.current; + const nextState = nextMessageInputStateAfterSend(currentState, submittedState, sendSucceeded); + latestStateRef.current = nextState; setText(nextState.text); - if (nextState.replyingTo === null) onCancelReply(); + if (currentState.replyingTo !== null && nextState.replyingTo === null) onCancelReply(); } finally { setIsSubmitting(false); textareaRef.current?.focus(); @@ -115,6 +133,7 @@ export function MessageInput({ placeholder={placeholder} value={text} onChange={e => { + latestStateRef.current = { ...latestStateRef.current, text: e.target.value }; setText(e.target.value); onTyping(); }} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/message-input-state.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/message-input-state.test.ts index cebb245e2b..6ab73293db 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/message-input-state.test.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/message-input-state.test.ts @@ -37,9 +37,45 @@ describe('nextMessageInputStateAfterSend', () => { it('preserves draft text and reply target after failed send', () => { const replyingTo = message({ id: 'reply-target' }); - expect(nextMessageInputStateAfterSend({ text: 'retry me', replyingTo }, false)).toStrictEqual({ - text: 'retry me', - replyingTo, - }); + expect( + nextMessageInputStateAfterSend( + { text: 'retry me', replyingTo }, + { text: 'retry me', replyingTo }, + false + ) + ).toStrictEqual({ text: 'retry me', replyingTo }); + }); + + it('keeps a newer draft after a deferred send succeeds', () => { + expect( + nextMessageInputStateAfterSend( + { text: 'newer draft', replyingTo: null }, + { text: 'sent draft', replyingTo: null }, + true + ) + ).toStrictEqual({ text: 'newer draft', replyingTo: null }); + }); + + it('keeps a newer draft after a deferred send fails', () => { + expect( + nextMessageInputStateAfterSend( + { text: 'newer draft', replyingTo: null }, + { text: 'sent draft', replyingTo: null }, + false + ) + ).toStrictEqual({ text: 'newer draft', replyingTo: null }); + }); + + it('keeps a newer reply target after a deferred send succeeds', () => { + const submittedReply = message({ id: 'submitted-reply' }); + const newerReply = message({ id: 'newer-reply' }); + + expect( + nextMessageInputStateAfterSend( + { text: 'sent draft', replyingTo: newerReply }, + { text: 'sent draft', replyingTo: submittedReply }, + true + ) + ).toStrictEqual({ text: '', replyingTo: newerReply }); }); }); From 393b17dd7679629a7f7c53c0cc56f4e3aef02533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 00:46:35 +0200 Subject: [PATCH 180/289] fix(kilo-chat): preserve sidebar state on leave rollback --- .../kilo-chat/components/KiloChatLayout.tsx | 38 ++------- .../src/use-conversations.test.ts | 73 +++++++++++++++++ .../kilo-chat-hooks/src/use-conversations.ts | 79 +++++++++++++++++++ 3 files changed, 159 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 09134b4131..9a5e836a05 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -16,8 +16,6 @@ import { useRenameConversation, useLeaveConversation, conversationsKey, - filterConversationPages, - type ConversationListInfiniteData, registerConversationListCacheHandlers, } from '../hooks/useConversations'; @@ -95,46 +93,24 @@ export function KiloChatLayout({ const handleLeave = useCallback( (conversationId: string) => { - // Mark as leaving so child queries disable themselves immediately + const isActiveConversation = params?.conversationId === conversationId; setLeavingConversationId(conversationId); - // Optimistically remove the row before the router.push fires. When the - // user leaves the *active* conversation, router navigation concurrent - // with the mutation's onSuccess invalidateQueries left the row stale - // in the sidebar until a full page reload. Patching the cache up-front - // mirrors what onConversationLeft does for other members. - const previous = queryClient.getQueriesData({ - queryKey: conversationsQueryKey, - }); - queryClient.setQueriesData( - { queryKey: conversationsQueryKey }, - old => filterConversationPages(old, c => c.conversationId !== conversationId) - ); - if (params?.conversationId === conversationId) { - router.push(basePath); - } leaveConversation.mutate( { sandboxId, conversationId }, { onSettled: () => setLeavingConversationId(null), - onError: err => { - // Restore the row on failure so the user can retry - for (const [key, data] of previous) { - queryClient.setQueryData(key, data); + onSuccess: () => { + if (isActiveConversation) { + router.push(basePath); } + }, + onError: err => { toast.error(formatKiloChatError(err, 'Failed to leave conversation')); }, } ); }, - [ - sandboxId, - leaveConversation.mutate, - params?.conversationId, - queryClient, - conversationsQueryKey, - router, - basePath, - ] + [sandboxId, leaveConversation.mutate, params?.conversationId, router, basePath] ); const handleNewConversation = useCallback(() => { diff --git a/packages/kilo-chat-hooks/src/use-conversations.test.ts b/packages/kilo-chat-hooks/src/use-conversations.test.ts index 1c996c01d3..ca092f6748 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.test.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.test.ts @@ -8,7 +8,9 @@ import { applyConversationActivityToPages, applyConversationCreatedToPages, applyConversationReadToPages, + applyOptimisticLeaveConversation, applyOptimisticMarkConversationRead, + rollbackOptimisticLeaveConversation, rollbackOptimisticMarkConversationRead, settleLeaveConversation, settleMarkConversationRead, @@ -353,6 +355,77 @@ describe('settleLeaveConversation', () => { }); }); +describe('optimistic leave conversation rollback', () => { + it('restores only the removed row while preserving newer sidebar patches', () => { + const queryClient = new QueryClient(); + const activeKey = conversationsKey('sandbox-a'); + + queryClient.setQueryData( + activeKey, + conversationsData( + [ + [ + conversation('conversation-leave', { lastActivityAt: 300, joinedAt: 300 }), + conversation('conversation-active', { lastActivityAt: 200, joinedAt: 200 }), + conversation('conversation-quiet', { lastActivityAt: 100, joinedAt: 100 }), + ], + ], + [null] + ) + ); + + const context = applyOptimisticLeaveConversation(queryClient, { + sandboxId: 'sandbox-a', + conversationId: 'conversation-leave', + }); + const activityResult = applyConversationActivityToPages(queryClient.getQueryData(activeKey), { + conversationId: 'conversation-quiet', + lastActivityAt: 500, + }); + queryClient.setQueryData(activeKey, activityResult.data); + const createResult = applyConversationCreatedToPages( + queryClient.getQueryData(activeKey), + conversation('conversation-created', { lastActivityAt: 450, joinedAt: 450 }) + ); + queryClient.setQueryData(activeKey, createResult.data); + queryClient.setQueryData(activeKey, old => + old + ? { + ...old, + pages: old.pages.map(page => ({ + ...page, + conversations: page.conversations.map(current => + current.conversationId === 'conversation-active' + ? { ...current, title: 'Renamed while leave was pending' } + : current + ), + })), + } + : old + ); + + rollbackOptimisticLeaveConversation(queryClient, context); + + const conversations = + queryClient + .getQueryData(activeKey) + ?.pages.flatMap(page => page.conversations) ?? []; + expect(conversations.map(current => current.conversationId)).toEqual([ + 'conversation-quiet', + 'conversation-created', + 'conversation-leave', + 'conversation-active', + ]); + expect( + conversations.find(current => current.conversationId === 'conversation-quiet') + ).toMatchObject({ lastActivityAt: 500 }); + expect( + conversations.find(current => current.conversationId === 'conversation-active') + ).toMatchObject({ title: 'Renamed while leave was pending' }); + expect(queryClient.getQueryState(activeKey)?.isInvalidated).toBe(false); + }); +}); + describe('applyOptimisticMarkConversationRead', () => { it('patches only the active sandbox conversation query when sandbox context is provided', () => { const queryClient = new QueryClient(); diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 83ac753dd8..3ed43e1716 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -124,6 +124,10 @@ export function useLeaveConversation(client: KiloChatClient) { return useMutation({ mutationFn: (variables: LeaveConversationMutationVariables) => client.leaveConversation(variables.conversationId), + onMutate: variables => applyOptimisticLeaveConversation(queryClient, variables), + onError: (_err, _variables, context) => { + rollbackOptimisticLeaveConversation(queryClient, context); + }, onSuccess: (_data, variables) => { settleLeaveConversation(queryClient, variables); }, @@ -353,6 +357,81 @@ export function settleLeaveConversation( }); } +type LeaveConversationQueryRollback = { + queryKey: QueryKey; + conversation: ConversationListItem; +}; + +type LeaveConversationMutationContext = { + rollbacks: LeaveConversationQueryRollback[]; + invalidationQueryKey: QueryKey; +}; + +export function applyOptimisticLeaveConversation( + queryClient: QueryClient, + variables: LeaveConversationMutationVariables +): LeaveConversationMutationContext { + const queryKey = conversationListInvalidationKey(variables.sandboxId); + const rollbacks: LeaveConversationQueryRollback[] = []; + const previousEntries = queryClient.getQueriesData({ + queryKey, + }); + + for (const [entryQueryKey, data] of previousEntries) { + const previousConversation = data?.pages + .flatMap(page => page.conversations) + .find(conversation => conversation.conversationId === variables.conversationId); + + if (!previousConversation) { + continue; + } + + rollbacks.push({ + queryKey: entryQueryKey, + conversation: previousConversation, + }); + + queryClient.setQueryData(entryQueryKey, old => + filterConversationPages( + old, + conversation => conversation.conversationId !== variables.conversationId + ) + ); + } + + return { rollbacks, invalidationQueryKey: queryKey }; +} + +export function rollbackOptimisticLeaveConversation( + queryClient: QueryClient, + context: LeaveConversationMutationContext | undefined +): void { + let shouldInvalidate = false; + + for (const rollback of context?.rollbacks ?? []) { + const current = queryClient.getQueryData(rollback.queryKey); + const alreadyRestored = current?.pages + .flatMap(page => page.conversations) + .some(conversation => conversation.conversationId === rollback.conversation.conversationId); + + if (alreadyRestored) { + continue; + } + + const result = applyConversationCreatedToPages(current, rollback.conversation); + if (!result.applied) { + shouldInvalidate = true; + continue; + } + + queryClient.setQueryData(rollback.queryKey, result.data); + } + + if (shouldInvalidate && context) { + void queryClient.invalidateQueries({ queryKey: context.invalidationQueryKey }); + } +} + export function applyConversationReadToPages( data: ConversationListInfiniteData | undefined, read: ConversationRead From 06441f1f4e8546e92675ac4794b8a7738dbdf8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:07:06 +0200 Subject: [PATCH 181/289] fix(notifications): trust typed rpc inputs --- .../src/__tests__/dispatch-push.test.ts | 36 ------------- .../send-push-for-conversation.test.ts | 23 -------- .../src/dos/NotificationChannelDO.ts | 52 ++++++++----------- services/notifications/src/index.ts | 45 +++++++--------- .../src/lib/instance-lifecycle-push.ts | 38 +++++++------- .../src/lib/notifications-service.test.ts | 22 -------- 6 files changed, 60 insertions(+), 156 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index 685ea46eec..7c9f6c8e20 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -82,42 +82,6 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(sendPushNotifications).not.toHaveBeenCalled(); }); - it('rejects malformed runtime payloads before side effects', async () => { - installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); - const presenceSpy = vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); - const stub = getDO('user-invalid-payload'); - const invalidInput = { - ...baseInput({ - userId: 'user-invalid-payload', - idempotencyKey: 'k-invalid-payload', - }), - push: { - title: 'T', - body: 'B', - data: { type: 'chat.message', sandboxId: '', conversationId: 'conv1', messageId: 'm1' }, - }, - } as DispatchPushInput; - - const stored = await runInDurableObject(stub, async (instance, state) => { - let rejected = false; - try { - await instance.dispatchPush(invalidInput); - } catch { - rejected = true; - } - return { - rejected, - buckets: Array.from((await state.storage.list({ prefix: 'bucket:' })).entries()), - total: await state.storage.get('total'), - idem: await state.storage.get<{ stage: string; ts: number }>('idem:k-invalid-payload'), - }; - }); - - expect(presenceSpy).not.toHaveBeenCalled(); - expect(sendPushNotifications).not.toHaveBeenCalled(); - expect(stored).toEqual({ rejected: true, buckets: [], total: undefined, idem: undefined }); - }); - it('records presence suppression as terminal idempotency', async () => { installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); const presenceSpy = vi diff --git a/services/notifications/src/__tests__/send-push-for-conversation.test.ts b/services/notifications/src/__tests__/send-push-for-conversation.test.ts index d8ef0ccf97..e43c09597f 100644 --- a/services/notifications/src/__tests__/send-push-for-conversation.test.ts +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -50,29 +50,6 @@ describe('NotificationsService.sendPushForConversation', () => { ); }); - it('rejects malformed runtime payloads before looking up recipient DOs', async () => { - const stubSpy = vi.fn(async (_input: DispatchPushInput) => ({ - kind: 'delivered' as const, - tokenCount: 1, - })); - const getRecipientDOStub = vi.fn(() => ({ - dispatchPush: stubSpy, - })); - - await expect( - sendPushForConversationCore( - { - ...baseInput(), - recipientUserIds: [''], - } as SendPushForConversationInput, - { getRecipientDOStub } - ) - ).rejects.toThrow(); - - expect(getRecipientDOStub).not.toHaveBeenCalled(); - expect(stubSpy).not.toHaveBeenCalled(); - }); - it('passes the right presence context and badge bucket', async () => { const stubSpy = vi.fn(async (_input: DispatchPushInput) => ({ kind: 'delivered' as const, diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 492bec604e..331423687b 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,11 +1,7 @@ import { DurableObject } from 'cloudflare:workers'; import { getWorkerDb } from '@kilocode/db/client'; import { user_push_tokens } from '@kilocode/db/schema'; -import { - dispatchPushInputSchema, - type DispatchPushInput, - type DispatchPushOutcome, -} from '@kilocode/notifications'; +import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/notifications'; import { eq, inArray } from 'drizzle-orm'; import type { ExpoPushMessage, SendResult, TicketTokenPair } from '../lib/expo-push'; @@ -30,12 +26,11 @@ const IDEM_TTL_MS = 60 * 60 * 1000; // 1 hour export class NotificationChannelDO extends DurableObject { async dispatchPush(input: DispatchPushInput): Promise { - const parsedInput = dispatchPushInputSchema.parse(input); // 1. Idempotency. DO is single-threaded — requests for a given // user serialize on this instance. Retryable send failures leave the // record at `pending` so upstream can retry the send without // re-incrementing the badge. - const idemKey = `${IDEM_PREFIX}${parsedInput.idempotencyKey}`; + const idemKey = `${IDEM_PREFIX}${input.idempotencyKey}`; const existing = await this.ctx.storage.get(idemKey); if ( existing?.stage === 'delivered' || @@ -50,14 +45,11 @@ export class NotificationChannelDO extends DurableObject { // 2. Presence let inContext = false; try { - inContext = await this.env.EVENT_SERVICE.isUserInContext( - parsedInput.userId, - parsedInput.presenceContext - ); + inContext = await this.env.EVENT_SERVICE.isUserInContext(input.userId, input.presenceContext); } catch (err) { console.warn('Presence lookup failed while dispatching push; continuing delivery', { - presenceContext: parsedInput.presenceContext, - badgeBucket: parsedInput.badge?.badgeBucket, + presenceContext: input.presenceContext, + badgeBucket: input.badge?.badgeBucket, error: err instanceof Error ? err.message : String(err), }); } @@ -75,7 +67,7 @@ export class NotificationChannelDO extends DurableObject { // The total is recomputed in either case (other writers may have // advanced it). let badgeTotal: number | undefined; - if (parsedInput.badge) { + if (input.badge) { if (!isRetry) { // Mark `pending` BEFORE the increment so any later failure path // is gated on the marker and a retry skips the increment. @@ -84,10 +76,7 @@ export class NotificationChannelDO extends DurableObject { // Also schedule cleanup at this point — if Expo keeps failing and // no future push ever lands, `pending` would otherwise leak. await this.ensureCleanupAlarm(ts); - badgeTotal = await this.incrementBucket( - parsedInput.badge.badgeBucket, - parsedInput.badge.delta - ); + badgeTotal = await this.incrementBucket(input.badge.badgeBucket, input.badge.delta); } else { badgeTotal = await this.getTotal(); } @@ -98,7 +87,7 @@ export class NotificationChannelDO extends DurableObject { const tokens = await db .select({ token: user_push_tokens.token }) .from(user_push_tokens) - .where(eq(user_push_tokens.user_id, parsedInput.userId)); + .where(eq(user_push_tokens.user_id, input.userId)); if (tokens.length === 0) { const ts = Date.now(); @@ -108,15 +97,18 @@ export class NotificationChannelDO extends DurableObject { } // 5. Send via Expo - const messages: ExpoPushMessage[] = tokens.map(({ token }) => ({ - to: token, - title: parsedInput.push.title, - body: parsedInput.push.body, - data: parsedInput.push.data, - ...(badgeTotal !== undefined && { badge: badgeTotal }), - sound: parsedInput.push.sound ?? undefined, - priority: parsedInput.push.priority ?? 'default', - })); + const messages: ExpoPushMessage[] = tokens.map( + ({ token }) => + ({ + to: token, + title: input.push.title, + body: input.push.body, + data: input.push.data, + ...(badgeTotal !== undefined && { badge: badgeTotal }), + sound: input.push.sound ?? undefined, + priority: input.push.priority ?? 'default', + }) satisfies ExpoPushMessage + ); const accessToken = await this.env.EXPO_ACCESS_TOKEN.get(); let result: SendResult; @@ -136,7 +128,9 @@ export class NotificationChannelDO extends DurableObject { } if (result.ticketTokenPairs.length > 0) { - const receiptMsg: ReceiptCheckMessage = { ticketTokenPairs: result.ticketTokenPairs }; + const receiptMsg = { + ticketTokenPairs: result.ticketTokenPairs, + } satisfies ReceiptCheckMessage; await this.env.RECEIPTS_QUEUE.send(receiptMsg, { delaySeconds: 900 }); } diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 7d5ac55a12..95c5fbfaad 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -10,9 +10,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'; import { presenceContextForConversation } from '@kilocode/event-service'; import { badgeBucketForConversation, - clearBadgeBucketForUserInputSchema, markBadgeReadInputSchema, - sendPushForConversationInputSchema, type ClearBadgeBucketForUserInput, type ClearBadgeBucketForUserOutput, type DispatchPushInput, @@ -97,11 +95,10 @@ export async function sendPushForConversationCore( getRecipientDOStub: (userId: string) => RecipientDOStub; } ): Promise { - const parsedInput = sendPushForConversationInputSchema.parse(input); const recipients: string[] = []; const seen = new Set(); - for (const id of parsedInput.recipientUserIds) { - if (id === parsedInput.senderUserId) continue; + for (const id of input.recipientUserIds) { + if (id === input.senderUserId) continue; if (seen.has(id)) continue; seen.add(id); recipients.push(id); @@ -110,33 +107,28 @@ export async function sendPushForConversationCore( const results = await Promise.allSettled( recipients.map(async userId => { const stub = deps.getRecipientDOStub(userId); - const outcome = await stub.dispatchPush({ + const dispatchInput = { userId, - presenceContext: presenceContextForConversation( - parsedInput.sandboxId, - parsedInput.conversationId - ), - idempotencyKey: `chat:${parsedInput.messageId}:${userId}`, + presenceContext: presenceContextForConversation(input.sandboxId, input.conversationId), + idempotencyKey: `chat:${input.messageId}:${userId}`, badge: { - badgeBucket: badgeBucketForConversation( - parsedInput.sandboxId, - parsedInput.conversationId - ), + badgeBucket: badgeBucketForConversation(input.sandboxId, input.conversationId), delta: 1, }, push: { - title: parsedInput.title, - body: parsedInput.bodyPreview, + title: input.title, + body: input.bodyPreview, data: { type: 'chat.message', - sandboxId: parsedInput.sandboxId, - conversationId: parsedInput.conversationId, - messageId: parsedInput.messageId, + sandboxId: input.sandboxId, + conversationId: input.conversationId, + messageId: input.messageId, }, sound: 'default', priority: 'high', }, - }); + } satisfies DispatchPushInput; + const outcome = await stub.dispatchPush(dispatchInput); return outcome.kind; }) ); @@ -147,7 +139,7 @@ export async function sendPushForConversationCore( outcome: result?.status === 'fulfilled' ? result.value : 'failed', }; }); - return { perRecipient }; + return { perRecipient } satisfies SendPushForConversationOutput; } /** @@ -180,12 +172,11 @@ export class NotificationsService extends WorkerEntrypoint { async clearBadgeBucketForUser( input: ClearBadgeBucketForUserInput ): Promise { - const parsedInput = clearBadgeBucketForUserInputSchema.parse(input); const stub = this.env.NOTIFICATION_CHANNEL_DO.get( - this.env.NOTIFICATION_CHANNEL_DO.idFromName(parsedInput.userId) + this.env.NOTIFICATION_CHANNEL_DO.idFromName(input.userId) ); - const badgeCount = await stub.markBucketRead(parsedInput.badgeBucket); - return { badgeCount }; + const badgeCount = await stub.markBucketRead(input.badgeBucket); + return { badgeCount } satisfies ClearBadgeBucketForUserOutput; } async sendInstanceLifecycleNotification( @@ -209,7 +200,7 @@ export class NotificationsService extends WorkerEntrypoint { return sendPushNotifications(messages, accessToken); }, enqueueReceipts: async ticketTokenPairs => { - const receiptMsg: ReceiptCheckMessage = { ticketTokenPairs }; + const receiptMsg = { ticketTokenPairs } satisfies ReceiptCheckMessage; await this.env.RECEIPTS_QUEUE.send(receiptMsg, { delaySeconds: 900 }); }, }); diff --git a/services/notifications/src/lib/instance-lifecycle-push.ts b/services/notifications/src/lib/instance-lifecycle-push.ts index 80524ea299..cf7ff4b39a 100644 --- a/services/notifications/src/lib/instance-lifecycle-push.ts +++ b/services/notifications/src/lib/instance-lifecycle-push.ts @@ -5,7 +5,6 @@ */ import { - sendInstanceLifecycleNotificationInputSchema, type InstanceLifecycleEvent, type SendInstanceLifecycleNotificationParams, type SendInstanceLifecycleNotificationResult, @@ -49,18 +48,21 @@ export function buildInstanceLifecycleMessages( const title = buildTitle(params.event, params.instanceName); const body = buildBody(params.event, params.errorMessage); - return tokens.map(token => ({ - to: token, - title, - body, - data: { - type: 'instance-lifecycle', - event: params.event, - sandboxId: params.sandboxId, - }, - sound: 'default' as const, - priority: 'high' as const, - })); + return tokens.map( + token => + ({ + to: token, + title, + body, + data: { + type: 'instance-lifecycle', + event: params.event, + sandboxId: params.sandboxId, + }, + sound: 'default', + priority: 'high', + }) satisfies ExpoPushMessage + ); } export type LifecycleDispatchDeps = { @@ -78,9 +80,7 @@ export async function dispatchInstanceLifecyclePush( params: SendInstanceLifecycleNotificationParams, deps: LifecycleDispatchDeps ): Promise { - const parsed = sendInstanceLifecycleNotificationInputSchema.parse(params); - - const tokens = await deps.getTokens(parsed.userId); + const tokens = await deps.getTokens(params.userId); if (tokens.length === 0) { return { tokenCount: 0, @@ -88,10 +88,10 @@ export async function dispatchInstanceLifecyclePush( staleTokens: 0, receiptCount: 0, ticketErrors: EMPTY_TICKET_ERRORS, - }; + } satisfies SendInstanceLifecycleNotificationResult; } - const messages = buildInstanceLifecycleMessages(tokens, parsed); + const messages = buildInstanceLifecycleMessages(tokens, params); const { ticketTokenPairs, staleTokens, ticketErrors } = await deps.sendPush(messages); if (staleTokens.length > 0) { @@ -112,5 +112,5 @@ export async function dispatchInstanceLifecyclePush( retryable: ticketErrors.filter(ticketError => ticketError.retryable).length, terminal: ticketErrors.filter(ticketError => !ticketError.retryable).length, }, - }; + } satisfies SendInstanceLifecycleNotificationResult; } diff --git a/services/notifications/src/lib/notifications-service.test.ts b/services/notifications/src/lib/notifications-service.test.ts index 6802379396..50ccbc91ef 100644 --- a/services/notifications/src/lib/notifications-service.test.ts +++ b/services/notifications/src/lib/notifications-service.test.ts @@ -258,28 +258,6 @@ describe('dispatchInstanceLifecyclePush', () => { expect(calls.enqueuedReceipts).toHaveLength(0); }); - it('rejects invalid params via zod before doing any IO', async () => { - const { deps, calls } = fakeDeps(); - - await expect( - dispatchInstanceLifecyclePush({ ...baseParams(), userId: '' }, deps) - ).rejects.toThrow(); - - expect(calls.getTokenQueries).toHaveLength(0); - expect(calls.sentMessages).toHaveLength(0); - }); - - it('rejects an empty sandboxId before doing any IO', async () => { - const { deps, calls } = fakeDeps(); - - await expect( - dispatchInstanceLifecyclePush({ ...baseParams(), sandboxId: '' }, deps) - ).rejects.toThrow(); - - expect(calls.getTokenQueries).toHaveLength(0); - expect(calls.sentMessages).toHaveLength(0); - }); - it('carries sandboxId as the only chat route id in the Expo data payload', async () => { const { deps, calls } = fakeDeps(); From 96d024e92689e5051e219af48b9219a8c8b6b16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:07:14 +0200 Subject: [PATCH 182/289] fix(kilo-chat): trust event rpc payloads --- .../src/__tests__/event-push.test.ts | 82 +++++++++++++------ services/kilo-chat/src/services/event-push.ts | 57 +------------ 2 files changed, 62 insertions(+), 77 deletions(-) diff --git a/services/kilo-chat/src/__tests__/event-push.test.ts b/services/kilo-chat/src/__tests__/event-push.test.ts index ab6133e86d..e369c146b4 100644 --- a/services/kilo-chat/src/__tests__/event-push.test.ts +++ b/services/kilo-chat/src/__tests__/event-push.test.ts @@ -26,24 +26,11 @@ describe('pushInstanceEventToUser', () => { lastReadAt: 123, }); }); - - it('does not push invalid payloads to a targeted user', async () => { - const pushEvent = vi.fn().mockResolvedValue(false); - const env = { EVENT_SERVICE: { pushEvent } } as unknown as Env; - - await pushInstanceEventToUser(env, 'sandbox-1', 'reader-1', 'conversation.read', { - conversationId, - memberId: 'reader-1', - lastReadAt: -1, - } as never); - - expect(pushEvent).not.toHaveBeenCalled(); - }); }); describe('pushEventToHumanMembers', () => { - it('does not push invalid payloads to conversation members', async () => { - const pushEvent = vi.fn().mockResolvedValue(false); + it('pushes typed payloads to conversation members', async () => { + const pushEvent = vi.fn().mockResolvedValue(true); const env = { EVENT_SERVICE: { pushEvent } } as unknown as Env; const result = await pushEventToHumanMembers( @@ -55,26 +42,75 @@ describe('pushEventToHumanMembers', () => { { conversationId, memberId: 'member-1', - lastReadAt: -1, - } as never + lastReadAt: 123, + } ); - expect(result.size).toBe(0); - expect(pushEvent).not.toHaveBeenCalled(); + expect(result).toEqual( + new Map([ + ['member-1', true], + ['member-2', true], + ]) + ); + expect(pushEvent).toHaveBeenCalledTimes(2); + expect(pushEvent).toHaveBeenNthCalledWith( + 1, + 'member-1', + `/kiloclaw/sandbox-1/${conversationId}`, + 'conversation.read', + { + conversationId, + memberId: 'member-1', + lastReadAt: 123, + } + ); + expect(pushEvent).toHaveBeenNthCalledWith( + 2, + 'member-2', + `/kiloclaw/sandbox-1/${conversationId}`, + 'conversation.read', + { + conversationId, + memberId: 'member-1', + lastReadAt: 123, + } + ); }); }); describe('pushInstanceEvent', () => { - it('does not push invalid payloads to instance members', async () => { + it('pushes typed payloads to instance members', async () => { const pushEvent = vi.fn().mockResolvedValue(false); const env = { EVENT_SERVICE: { pushEvent } } as unknown as Env; await pushInstanceEvent(env, 'sandbox-1', ['member-1', 'member-2'], 'conversation.read', { conversationId, memberId: 'member-1', - lastReadAt: -1, - } as never); + lastReadAt: 123, + }); - expect(pushEvent).not.toHaveBeenCalled(); + expect(pushEvent).toHaveBeenCalledTimes(2); + expect(pushEvent).toHaveBeenNthCalledWith( + 1, + 'member-1', + '/kiloclaw/sandbox-1', + 'conversation.read', + { + conversationId, + memberId: 'member-1', + lastReadAt: 123, + } + ); + expect(pushEvent).toHaveBeenNthCalledWith( + 2, + 'member-2', + '/kiloclaw/sandbox-1', + 'conversation.read', + { + conversationId, + memberId: 'member-1', + lastReadAt: 123, + } + ); }); }); diff --git a/services/kilo-chat/src/services/event-push.ts b/services/kilo-chat/src/services/event-push.ts index 5beca671d6..9181f56e04 100644 --- a/services/kilo-chat/src/services/event-push.ts +++ b/services/kilo-chat/src/services/event-push.ts @@ -4,7 +4,6 @@ import type { BotStatusRequest, ConversationStatusRequest, } from '@kilocode/kilo-chat'; -import { getKiloChatEventPayloadSchema } from '@kilocode/kilo-chat'; import { kiloclawConversationContext, kiloclawInstanceContext } from '@kilocode/event-service'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; @@ -14,29 +13,6 @@ function getEventService(env: Env): Env['EVENT_SERVICE'] | null { return env.EVENT_SERVICE ?? null; } -function compactIssueSummary(issues: Array<{ path: PropertyKey[]; message: string }>): string[] { - return issues.slice(0, 5).map(issue => { - const path = issue.path.length > 0 ? issue.path.join('.') : ''; - return `${path}: ${issue.message}`; - }); -} - -function parseEventPayload( - event: KiloChatEventName, - payload: unknown, - target: Record -): KiloChatEventOf | null { - const parsed = getKiloChatEventPayloadSchema(event).safeParse(payload); - if (parsed.success) return parsed.data; - - logger.error('event-service pushEvent rejected invalid kilo-chat payload', { - event, - ...target, - issues: compactIssueSummary(parsed.error.issues), - }); - return null; -} - /** * Pushes an event to the event-service for each human member of a conversation. * Returns a map of userId → delivered (true if the user had an active WS in context). @@ -52,22 +28,10 @@ export async function pushEventToHumanMembers( const es = getEventService(env); if (!es) return new Map(); const context = kiloclawConversationContext(sandboxId, conversationId); - const parsedPayload = parseEventPayload(event, payload, { - context, - conversationId, - sandboxId, - target: 'conversation-members', - }); - if (!parsedPayload) return new Map(); const results = await Promise.allSettled( humanMemberIds.map(async userId => { - const delivered = await es.pushEvent( - userId, - context, - event, - parsedPayload - ); + const delivered = await es.pushEvent(userId, context, event, payload); return [userId, delivered] as const; }) ); @@ -103,17 +67,9 @@ export async function pushInstanceEvent( const es = getEventService(env); if (!es) return; const context = kiloclawInstanceContext(sandboxId); - const parsedPayload = parseEventPayload(event, payload, { - context, - sandboxId, - target: 'instance-members', - }); - if (!parsedPayload) return; const results = await Promise.allSettled( - humanMemberIds.map(userId => - es.pushEvent(userId, context, event, parsedPayload) - ) + humanMemberIds.map(userId => es.pushEvent(userId, context, event, payload)) ); for (let i = 0; i < results.length; i++) { const r = results[i]; @@ -143,16 +99,9 @@ export async function pushInstanceEventToUser( const es = getEventService(env); if (!es) return; const context = kiloclawInstanceContext(sandboxId); - const parsedPayload = parseEventPayload(event, payload, { - context, - sandboxId, - target: 'instance-user', - userId, - }); - if (!parsedPayload) return; try { - await es.pushEvent(userId, context, event, parsedPayload); + await es.pushEvent(userId, context, event, payload); } catch (err) { logger.error('event-service pushEvent failed for instance user', { userId, From bc4046f182e1b3fa90bb0d8a918763be03008392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:07:20 +0200 Subject: [PATCH 183/289] fix(kiloclaw): trust chat webhook rpc input --- services/kiloclaw/src/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/services/kiloclaw/src/index.ts b/services/kiloclaw/src/index.ts index 1faaf0a9ac..04fd3d82de 100644 --- a/services/kiloclaw/src/index.ts +++ b/services/kiloclaw/src/index.ts @@ -16,7 +16,6 @@ import type { Context, Next } from 'hono'; import { Hono } from 'hono'; import { getCookie, deleteCookie } from 'hono/cookie'; -import { chatWebhookRpcSchema } from '@kilocode/kilo-chat'; import type { AppEnv, KiloClawEnv, ChatWebhookPayload } from './types'; import type { SnapshotRestoreMessage } from './schemas/snapshot-restore'; import { accessGatewayRoutes, publicRoutes, api, kiloclaw, platform, controller } from './routes'; @@ -1078,12 +1077,12 @@ export default class extends WorkerEntrypoint { * stale-online until staleness inference catches up, ~poll interval). */ async deliverChatWebhook(payload: ChatWebhookPayload): Promise { - const parsed = chatWebhookRpcSchema.parse(payload); + const { targetBotId, ...webhookPayload } = payload; const botPrefix = 'bot:kiloclaw:'; - if (!parsed.targetBotId.startsWith(botPrefix)) { - throw new Error(`Invalid targetBotId: ${parsed.targetBotId}`); + if (!targetBotId.startsWith(botPrefix)) { + throw new Error(`Invalid targetBotId: ${targetBotId}`); } - const sandboxId = parsed.targetBotId.slice(botPrefix.length); + const sandboxId = targetBotId.slice(botPrefix.length); const { doKey, label } = await this.resolveChatWebhookDoKey(sandboxId); const getWebhookStub = () => @@ -1124,7 +1123,6 @@ export default class extends WorkerEntrypoint { ); // Forward the webhook payload (without targetBotId) to the controller - const { targetBotId: _, ...webhookPayload } = parsed; const body = JSON.stringify(webhookPayload); const controller = new AbortController(); From 590b0e894eee9b5f2f4637baea19dd30b51aeb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:07:27 +0200 Subject: [PATCH 184/289] fix(web): trust kilo chat token output --- apps/web/src/lib/kilo-chat/token.ts | 4 ++-- apps/web/src/routers/kilo-chat-router.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/kilo-chat/token.ts b/apps/web/src/lib/kilo-chat/token.ts index f0b9c6e45d..1c4f065b95 100644 --- a/apps/web/src/lib/kilo-chat/token.ts +++ b/apps/web/src/lib/kilo-chat/token.ts @@ -3,7 +3,7 @@ import type { User } from '@kilocode/db/schema'; import { generateApiToken } from '@/lib/tokens'; -import { kiloChatTokenResponseSchema, type KiloChatTokenResponse } from './token-schema'; +import type { KiloChatTokenResponse } from './token-schema'; const KILO_CHAT_TOKEN_TTL_SECONDS = 60 * 60; @@ -14,5 +14,5 @@ export function createKiloChatTokenResponse(user: User): KiloChatTokenResponse { { expiresIn: KILO_CHAT_TOKEN_TTL_SECONDS } ); const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_SECONDS * 1000).toISOString(); - return kiloChatTokenResponseSchema.parse({ token, expiresAt, userId: user.id }); + return { token, expiresAt, userId: user.id } satisfies KiloChatTokenResponse; } diff --git a/apps/web/src/routers/kilo-chat-router.ts b/apps/web/src/routers/kilo-chat-router.ts index 5a9f9e0d0d..752bd6d7f5 100644 --- a/apps/web/src/routers/kilo-chat-router.ts +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -1,10 +1,7 @@ import 'server-only'; import { createKiloChatTokenResponse } from '@/lib/kilo-chat/token'; -import { kiloChatTokenResponseSchema } from '@/lib/kilo-chat/token-schema'; import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; export const kiloChatRouter = createTRPCRouter({ - getToken: baseProcedure - .output(kiloChatTokenResponseSchema) - .query(({ ctx }) => createKiloChatTokenResponse(ctx.user)), + getToken: baseProcedure.query(({ ctx }) => createKiloChatTokenResponse(ctx.user)), }); From ba297c0a5f1d76ed26b63d1a34da3a85c7ac5eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:07:35 +0200 Subject: [PATCH 185/289] fix(kilo-chat): reject invalid delivery failure bodies --- .../src/__tests__/bot-messages-routes.test.ts | 27 +++++++++++++++++++ services/kilo-chat/src/routes/handler.ts | 18 ++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index 76717ddf44..38760e7caa 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -280,6 +280,33 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai expect(pushEvent).not.toHaveBeenCalled(); }); + it('returns 400 when the diagnostic body has an invalid shape', async () => { + const pushEvent = vi.fn().mockResolvedValue(false); + const { sandboxId, conversationId, messageId, testEnv } = await setupData( + 'bot-msg-df-invalid-body', + pushEvent + ); + const app = makeBotApp(); + const token = await tokenFor(sandboxId); + pushEvent.mockClear(); + + const res = await app.request( + `/bot/v1/sandboxes/${sandboxId}/conversations/${conversationId}/messages/${messageId}/delivery-failed`, + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ reason: 123 }), + }, + testEnv + ); + + expect(res.status).toBe(400); + const body = await res.json<{ error: string; issues: unknown[] }>(); + expect(body.error).toBe('Invalid request'); + expect(body.issues.length).toBeGreaterThan(0); + expect(pushEvent).not.toHaveBeenCalled(); + }); + it('returns 401 without auth token', async () => { const { sandboxId, conversationId, messageId, testEnv } = await setupData('bot-msg-df-noauth'); const app = makeBotApp(); diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index 004574c6a2..e77f97b9c2 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -250,14 +250,20 @@ export async function handleMessageDeliveryFailed(c: HonoCtx) { const membership = await assertCallerIsMember(c, convId.data, callerId); if (!membership.ok) return membership.response; - // Accept empty body. Validate when present but never fail on shape. + // Existing clients may omit this diagnostic body; validate it when supplied. let body: unknown = {}; - try { - body = await c.req.json(); - } catch { - body = {}; + const rawBody = await c.req.text(); + if (rawBody.length > 0) { + try { + body = JSON.parse(rawBody); + } catch { + return c.json({ error: 'Invalid JSON' }, 400); + } + } + const parsed = messageDeliveryFailedRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'Invalid request', issues: parsed.error.issues }, 400); } - messageDeliveryFailedRequestSchema.safeParse(body); const result = await notifyMessageDeliveryFailed(c.env, { conversationId: convId.data, From b97412c7c2ac678235c77d4c127fb2428865eb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:24:48 +0200 Subject: [PATCH 186/289] fix(kiloclaw): type kilo-chat request bodies --- .../kiloclaw/plugins/kilo-chat/src/client.ts | 97 +++++++++++++------ 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/services/kiloclaw/plugins/kilo-chat/src/client.ts b/services/kiloclaw/plugins/kilo-chat/src/client.ts index 31daccac4b..4872ea9d67 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/client.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/client.ts @@ -158,16 +158,18 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl const headers = authHeaders(options.gatewayToken); async function createMessage(params: CreateMessageParams): Promise { + const body = { + conversationId: params.conversationId, + content: params.content, + ...(params.inReplyToMessageId !== undefined && { + inReplyToMessageId: params.inReplyToMessageId, + }), + } satisfies z.input; + const response = await fetchImpl(`${base}/_kilo/kilo-chat/send`, { method: 'POST', headers, - body: JSON.stringify({ - conversationId: params.conversationId, - content: params.content, - ...(params.inReplyToMessageId !== undefined && { - inReplyToMessageId: params.inReplyToMessageId, - }), - }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error( @@ -178,16 +180,18 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl } async function editMessage(params: EditMessageParams): Promise { + const body = { + conversationId: params.conversationId, + content: params.content, + timestamp: params.timestamp, + } satisfies z.input; + const response = await fetchImpl( `${base}/_kilo/kilo-chat/messages/${encodeURIComponent(params.messageId)}`, { method: 'PATCH', headers, - body: JSON.stringify({ - conversationId: params.conversationId, - content: params.content, - timestamp: params.timestamp, - }), + body: JSON.stringify(body), } ); if (response.status === 409) { @@ -221,10 +225,14 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl } async function sendTyping(params: SendTypingParams): Promise { + const body = { + conversationId: params.conversationId, + } satisfies SendTypingParams; + const response = await fetchImpl(`${base}/_kilo/kilo-chat/typing`, { method: 'POST', headers, - body: JSON.stringify({ conversationId: params.conversationId }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error( @@ -235,10 +243,14 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl } async function sendTypingStop(params: SendTypingParams): Promise { + const body = { + conversationId: params.conversationId, + } satisfies SendTypingParams; + const response = await fetchImpl(`${base}/_kilo/kilo-chat/typing/stop`, { method: 'POST', headers, - body: JSON.stringify({ conversationId: params.conversationId }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error( @@ -249,12 +261,17 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl } async function addReaction(params: AddReactionParams): Promise { + const body = { + conversationId: params.conversationId, + emoji: params.emoji, + } satisfies z.input; + const response = await fetchImpl( `${base}/_kilo/kilo-chat/messages/${encodeURIComponent(params.messageId)}/reactions`, { method: 'POST', headers, - body: JSON.stringify({ conversationId: params.conversationId, emoji: params.emoji }), + body: JSON.stringify(body), } ); if (!response.ok) { @@ -316,12 +333,16 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl } async function renameConversation(params: RenameConversationParams): Promise { + const body = { + title: params.title, + } satisfies z.input; + const response = await fetchImpl( `${base}/_kilo/kilo-chat/conversations/${encodeURIComponent(params.conversationId)}`, { method: 'PATCH', headers, - body: JSON.stringify({ title: params.title }), + body: JSON.stringify(body), } ); if (!response.ok) { @@ -352,12 +373,14 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl async function createConversation( params: CreateConversationParams ): Promise { + const body = { + ...(params.title !== undefined && { title: params.title }), + } satisfies z.input; + const response = await fetchImpl(`${base}/_kilo/kilo-chat/conversations`, { method: 'POST', headers, - body: JSON.stringify({ - ...(params.title !== undefined && { title: params.title }), - }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error( @@ -374,10 +397,15 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl async function sendBotStatus(params: BotStatusParams): Promise { try { + const body = { + online: params.online, + at: params.at, + } satisfies z.input; + const response = await fetchImpl(`${base}/_kilo/kilo-chat/bot-status`, { method: 'POST', headers, - body: JSON.stringify(params), + body: JSON.stringify(body), }); if (!response.ok) { console.warn( @@ -393,9 +421,16 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl async function sendConversationStatus(params: ConversationStatusParams): Promise { try { - const { conversationId, ...body } = params; + const body = { + contextTokens: params.contextTokens, + contextWindow: params.contextWindow, + model: params.model, + provider: params.provider, + at: params.at, + } satisfies z.input; + const response = await fetchImpl( - `${base}/_kilo/kilo-chat/conversations/${encodeURIComponent(conversationId)}/conversation-status`, + `${base}/_kilo/kilo-chat/conversations/${encodeURIComponent(params.conversationId)}/conversation-status`, { method: 'POST', headers, @@ -418,6 +453,10 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl params: ReportMessageDeliveryFailedParams ): Promise { try { + const body = { + ...(params.reason !== undefined && { reason: params.reason }), + } satisfies z.input; + const response = await fetchImpl( `${base}/_kilo/kilo-chat/conversations/${encodeURIComponent( params.conversationId @@ -425,9 +464,7 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl { method: 'POST', headers, - body: JSON.stringify({ - ...(params.reason !== undefined && { reason: params.reason }), - }), + body: JSON.stringify(body), } ); if (!response.ok) { @@ -446,6 +483,11 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl params: ReportActionDeliveryFailedParams ): Promise { try { + const body = { + messageId: params.messageId, + ...(params.reason !== undefined && { reason: params.reason }), + } satisfies z.input; + const response = await fetchImpl( `${base}/_kilo/kilo-chat/conversations/${encodeURIComponent( params.conversationId @@ -453,10 +495,7 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl { method: 'POST', headers, - body: JSON.stringify({ - messageId: params.messageId, - ...(params.reason !== undefined && { reason: params.reason }), - }), + body: JSON.stringify(body), } ); if (!response.ok) { From 4a6f54eeb297216ab85450904b1244e37d412ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:25:24 +0200 Subject: [PATCH 187/289] fix(kilo-chat): type client request payloads --- packages/kilo-chat/src/client.ts | 52 ++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/kilo-chat/src/client.ts b/packages/kilo-chat/src/client.ts index ae0ad8ef03..ad83bf6b5e 100644 --- a/packages/kilo-chat/src/client.ts +++ b/packages/kilo-chat/src/client.ts @@ -88,12 +88,13 @@ export class KiloChatClient { // ── Mutations via HTTP ──────────────────────────────────────────────────── async sendMessage(req: CreateMessageRequest): Promise { + const body = req satisfies CreateMessageRequest; const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve(); const next = prev.then( () => this.httpRequest('/v1/messages', { method: 'POST', - body: req, + body, schema: createMessageResponseSchema, }), // A failed prior send must not block subsequent sends — swallow the @@ -101,7 +102,7 @@ export class KiloChatClient { () => this.httpRequest('/v1/messages', { method: 'POST', - body: req, + body, schema: createMessageResponseSchema, }) ); @@ -118,9 +119,11 @@ export class KiloChatClient { } async editMessage(messageId: string, req: EditMessageRequest): Promise { + const body = req satisfies EditMessageRequest; + return this.httpRequest(`/v1/messages/${messageId}`, { method: 'PATCH', - body: req, + body, schema: editMessageResponseSchema, }); } @@ -129,17 +132,21 @@ export class KiloChatClient { messageId: string, req: z.input ): Promise { + const query = req satisfies z.input; + await this.httpRequest(`/v1/messages/${messageId}`, { method: 'DELETE', - query: req, + query, schema: voidSchema, }); } async createConversation(req: CreateConversationRequest): Promise { + const body = req satisfies CreateConversationRequest; + return this.httpRequest('/v1/conversations', { method: 'POST', - body: req, + body, schema: createConversationResponseSchema, }); } @@ -148,9 +155,11 @@ export class KiloChatClient { conversationId: string, req: RenameConversationRequest ): Promise<{ ok: true }> { + const body = req satisfies RenameConversationRequest; + return this.httpRequest(`/v1/conversations/${conversationId}`, { method: 'PATCH', - body: req, + body, schema: okResponseSchema, }); } @@ -180,9 +189,11 @@ export class KiloChatClient { conversationId: string, req: MarkConversationReadRequest ): Promise { + const body = req satisfies MarkConversationReadRequest; + return this.httpRequest(`/v1/conversations/${conversationId}/mark-read`, { method: 'POST', - body: req, + body, schema: markConversationReadResponseSchema, }); } @@ -191,9 +202,11 @@ export class KiloChatClient { messageId: string, req: z.input ): Promise { + const body = req satisfies z.input; + return this.httpRequest(`/v1/messages/${messageId}/reactions`, { method: 'POST', - body: req, + body, schema: addReactionResponseSchema, }); } @@ -202,9 +215,11 @@ export class KiloChatClient { messageId: string, req: z.input ): Promise { + const query = req satisfies z.input; + return this.httpRequest(`/v1/messages/${messageId}/reactions`, { method: 'DELETE', - query: req, + query, schema: removeReactionResponseSchema, }); } @@ -214,9 +229,11 @@ export class KiloChatClient { messageId: string, req: z.input ): Promise { + const body = req satisfies z.input; + return this.httpRequest( `/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, - { method: 'POST', body: req, schema: executeActionResponseSchema } + { method: 'POST', body, schema: executeActionResponseSchema } ); } @@ -225,8 +242,14 @@ export class KiloChatClient { async listConversations( opts?: z.input ): Promise { + const query = { + sandboxId: opts?.sandboxId, + limit: opts?.limit, + cursor: opts?.cursor, + } satisfies z.input; + return this.httpRequest('/v1/conversations', { - query: { sandboxId: opts?.sandboxId, limit: opts?.limit, cursor: opts?.cursor }, + query, schema: conversationListResponseSchema, }); } @@ -271,8 +294,13 @@ export class KiloChatClient { conversationId: string, opts?: z.input ): Promise { + const query = { + before: opts?.before, + limit: opts?.limit, + } satisfies z.input; + return this.httpRequest(`/v1/conversations/${conversationId}/messages`, { - query: { before: opts?.before, limit: opts?.limit }, + query, schema: messageListResponseSchema, }); } From 4c7904a85909eea95772e3bc3d96a3d97ca079e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:26:18 +0200 Subject: [PATCH 188/289] fix(event-service): validate connect ticket responses --- packages/event-service/src/client.ts | 13 ++----------- packages/event-service/src/schemas.ts | 6 ++++++ packages/event-service/src/types.ts | 5 +++++ services/event-service/src/index.ts | 4 +++- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 9927738f21..00aaf55a48 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -1,5 +1,5 @@ import type { ClientMessage, EventServiceConfig } from './types'; -import { serverMessageSchema } from './schemas'; +import { connectTicketResponseSchema, serverMessageSchema } from './schemas'; const WEBSOCKET_PROTOCOL = 'kilo.events.v1'; @@ -59,16 +59,7 @@ async function fetchConnectionTicket(wsBase: string, token: string): Promise; export type ErrorMessage = z.infer; export type ServerMessage = z.infer; +// ── HTTP Responses ───────────────────────────────────────────────── + +export type ConnectTicketResponse = z.infer; + // ── Config ───────────────────────────────────────────────────────── export type UnauthorizedRecoveryDecision = 'retry' | 'stop'; diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 0afce703d1..4dffef6045 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { useWorkersLogger } from 'workers-tagged-logger'; import type { MiddlewareHandler } from 'hono'; +import type { ConnectTicketResponse } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; import { logger } from './util/logger'; @@ -82,7 +83,8 @@ app.post('/connect-ticket', async c => { return c.json({ error: 'Ticket mint failed' }, 500); } - return c.json({ ticket }); + const response = { ticket } satisfies ConnectTicketResponse; + return c.json(response); }); app.get('/connect', async c => { From ea117ddda4e7448a5d5a35454120014d80b716c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:27:09 +0200 Subject: [PATCH 189/289] fix(event-service): type websocket messages --- packages/event-service/src/client.ts | 17 +++++++++++++---- .../event-service/src/do/user-session-do.ts | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 00aaf55a48..7a5908dbf8 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -278,7 +278,11 @@ export class EventServiceClient { if (next === 1) newlyActive.push(ctx); } if (newlyActive.length > 0 && this.isConnected()) { - this.send({ type: 'context.subscribe', contexts: newlyActive }); + const message = { + type: 'context.subscribe', + contexts: newlyActive, + } satisfies ClientMessage; + this.send(message); } } @@ -295,7 +299,11 @@ export class EventServiceClient { } } if (released.length > 0 && this.isConnected()) { - this.send({ type: 'context.unsubscribe', contexts: released }); + const message = { + type: 'context.unsubscribe', + contexts: released, + } satisfies ClientMessage; + this.send(message); } } @@ -376,10 +384,11 @@ export class EventServiceClient { private resubscribeContexts(): void { if (this.activeContexts.size > 0) { - this.send({ + const message = { type: 'context.subscribe', contexts: Array.from(this.activeContexts.keys()), - }); + } satisfies ClientMessage; + this.send(message); } } diff --git a/services/event-service/src/do/user-session-do.ts b/services/event-service/src/do/user-session-do.ts index 95adcdbb20..bb9b79cf16 100644 --- a/services/event-service/src/do/user-session-do.ts +++ b/services/event-service/src/do/user-session-do.ts @@ -53,11 +53,11 @@ export class UserSessionDO extends DurableObject { } this.saveState(ws, state); if (overflowed) { - const errorMsg: ServerMessage = { + const errorMsg = { type: 'error', code: 'too_many_contexts', max: MAX_CONTEXTS, - }; + } satisfies ServerMessage; try { ws.send(JSON.stringify(errorMsg)); } catch { @@ -95,7 +95,7 @@ export class UserSessionDO extends DurableObject { logger.setTags({ userId: this.ctx.id.name, context, event }); const sockets = this.ctx.getWebSockets(); - const message: ServerMessage = { type: 'event', context, event, payload }; + const message = { type: 'event', context, event, payload } satisfies ServerMessage; const text = JSON.stringify(message); let delivered = false; From ff1611ac90e74e597b8831e74d5b9b2ca4894cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:28:18 +0200 Subject: [PATCH 190/289] fix(kilo-chat): type notification rpc payloads --- .../kilo-chat/src/services/bot-status-request.ts | 8 +++++--- services/kilo-chat/src/services/conversations.ts | 6 ++++-- services/kilo-chat/src/services/messages.ts | 6 ++++-- .../kiloclaw-instance/lifecycle-push.ts | 15 ++++++++++----- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/services/kilo-chat/src/services/bot-status-request.ts b/services/kilo-chat/src/services/bot-status-request.ts index 8b93865803..1ecee5e0a5 100644 --- a/services/kilo-chat/src/services/bot-status-request.ts +++ b/services/kilo-chat/src/services/bot-status-request.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; +import type { z } from 'zod'; import type { AuthContext } from '../auth'; -import { sandboxIdSchema } from '@kilocode/kilo-chat'; +import { sandboxIdSchema, type chatWebhookRpcSchema } from '@kilocode/kilo-chat'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; import { userOwnsSandbox } from './sandbox-ownership'; @@ -59,10 +60,11 @@ export async function handleRequestBotStatus(c: HonoCtx): Promise { */ async function triggerBotStatusWebhook(env: Env, sandboxId: string): Promise { try { - await env.KILOCLAW.deliverChatWebhook({ + const payload = { type: 'bot.status_request', targetBotId: `bot:kiloclaw:${sandboxId}`, - }); + } satisfies z.infer; + await env.KILOCLAW.deliverChatWebhook(payload); } catch (err) { if (isDefiniteUnreachable(err)) { logger.warn('bot.status_request: bot unreachable, publishing offline', { diff --git a/services/kilo-chat/src/services/conversations.ts b/services/kilo-chat/src/services/conversations.ts index 7e40dc8547..a9031a31fc 100644 --- a/services/kilo-chat/src/services/conversations.ts +++ b/services/kilo-chat/src/services/conversations.ts @@ -7,6 +7,7 @@ import { ulid } from 'ulid'; import { ulidToTimestamp } from '@kilocode/kilo-chat'; import type { ConversationListItem } from '@kilocode/kilo-chat'; import { badgeBucketForConversation } from '@kilocode/notifications'; +import type { ClearBadgeBucketForUserInput } from '@kilocode/notifications'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { extractConversationContext, @@ -386,10 +387,11 @@ export async function markReadFor( ) { const badgeBucket = badgeBucketForConversation(sandboxId, conversationId); try { - const clearResult = await env.NOTIFICATIONS.clearBadgeBucketForUser({ + const payload = { userId, badgeBucket, - }); + } satisfies ClearBadgeBucketForUserInput; + const clearResult = await env.NOTIFICATIONS.clearBadgeBucketForUser(payload); badgeClear = { ...clearResult, badgeBucket }; } catch (err) { logger.error('clearBadgeBucketForUser failed', { diff --git a/services/kilo-chat/src/services/messages.ts b/services/kilo-chat/src/services/messages.ts index 4de175dc01..416913393d 100644 --- a/services/kilo-chat/src/services/messages.ts +++ b/services/kilo-chat/src/services/messages.ts @@ -15,6 +15,7 @@ import { type Message, type ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; +import type { SendPushForConversationInput } from '@kilocode/notifications'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; import { contentBlocksToText } from '../util/content'; @@ -343,7 +344,7 @@ export async function postCommitFanOut( const bodyPreview = contentBlocksToText(content).slice(0, 200); const sandboxLabel = await fetchSandboxLabel(env.HYPERDRIVE.connectionString, sandboxId); const conversationTitle = info.title ?? appliedAutoTitle ?? 'Untitled'; - const pushResult = await env.NOTIFICATIONS.sendPushForConversation({ + const payload = { conversationId, sandboxId, senderUserId, @@ -351,7 +352,8 @@ export async function postCommitFanOut( title: `${sandboxLabel} · ${conversationTitle}`, bodyPreview, messageId, - }); + } satisfies SendPushForConversationInput; + const pushResult = await env.NOTIFICATIONS.sendPushForConversation(payload); const failedRecipients = pushResult.perRecipient.filter( result => result.outcome === 'failed' ); diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/lifecycle-push.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/lifecycle-push.ts index 83d054a44a..df9f5ea0b0 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/lifecycle-push.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/lifecycle-push.ts @@ -18,7 +18,10 @@ import type { KiloClawEnv } from '../../types'; import type { InstanceMutableState } from './types'; import { storageUpdate } from './state'; import { doLog, doWarn, toLoggable } from './log'; -import type { SendInstanceLifecycleNotificationResult } from '../../notifications-binding'; +import type { + SendInstanceLifecycleNotificationParams, + SendInstanceLifecycleNotificationResult, +} from '../../notifications-binding'; /** * Reset shape for the lifecycle notification flags. Spread this into storage @@ -129,12 +132,13 @@ export async function dispatchReadyPush( const instanceName = await lookupInstanceName(env, state); try { - const result = await env.NOTIFICATIONS.sendInstanceLifecycleNotification({ + const payload = { userId: state.userId, sandboxId: state.sandboxId, event: 'ready', instanceName, - }); + } satisfies SendInstanceLifecycleNotificationParams; + const result = await env.NOTIFICATIONS.sendInstanceLifecycleNotification(payload); logLifecyclePushResult(state, 'ready push dispatch completed', result, { event: 'ready', sandboxId: state.sandboxId, @@ -169,13 +173,14 @@ export async function maybeDispatchStartFailurePush( const errorText = formatStartFailureReason(label); try { - const result = await env.NOTIFICATIONS.sendInstanceLifecycleNotification({ + const payload = { userId: state.userId, sandboxId: state.sandboxId, event: 'start_failed', instanceName, errorMessage: errorText, - }); + } satisfies SendInstanceLifecycleNotificationParams; + const result = await env.NOTIFICATIONS.sendInstanceLifecycleNotification(payload); logLifecyclePushResult(state, 'start failure push dispatch completed', result, { event: 'start_failed', sandboxId: state.sandboxId, From 99db62329b97cf73f5729e3f6c18a33f8fd2e2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:29:07 +0200 Subject: [PATCH 191/289] fix(event-service): type ticket mint requests --- services/event-service/src/do/connection-ticket-do.ts | 7 ++++++- services/event-service/src/index.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/services/event-service/src/do/connection-ticket-do.ts b/services/event-service/src/do/connection-ticket-do.ts index 93312e6d2d..b572655ade 100644 --- a/services/event-service/src/do/connection-ticket-do.ts +++ b/services/event-service/src/do/connection-ticket-do.ts @@ -7,7 +7,10 @@ const ticketStateSchema = z.object({ consumed: z.boolean().optional(), }); +const ticketMintRequestSchema = ticketStateSchema.omit({ consumed: true }); + type TicketState = z.infer; +export type TicketMintRequest = z.infer; export class ConnectionTicketDO extends DurableObject { async fetch(request: Request): Promise { @@ -23,7 +26,9 @@ export class ConnectionTicketDO extends DurableObject { private async mint(request: Request): Promise { const body: unknown = await request.json().catch(() => null); - const parsed = ticketStateSchema.omit({ consumed: true }).safeParse(body); + // This DO still exposes an HTTP-shaped fetch endpoint, so validate the + // serialized JSON even though the caller is internal service code. + const parsed = ticketMintRequestSchema.safeParse(body); if (!parsed.success) { return new Response('Invalid ticket', { status: 400 }); } diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 4dffef6045..6a8d1d9226 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -7,6 +7,7 @@ import type { ConnectTicketResponse } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; import { logger } from './util/logger'; +import type { TicketMintRequest } from './do/connection-ticket-do'; export { UserSessionDO } from './do/user-session-do'; export { ConnectionTicketDO } from './do/connection-ticket-do'; @@ -45,12 +46,13 @@ function acceptsWebSocketProtocol(header: string | undefined): boolean { async function mintConnectionTicket(env: Env, userId: string): Promise { const ticket = crypto.randomUUID(); const stub = env.CONNECTION_TICKET_DO.get(env.CONNECTION_TICKET_DO.idFromName(ticket)); + const body = { + userId, + expiresAt: Date.now() + CONNECTION_TICKET_TTL_MS, + } satisfies TicketMintRequest; const res = await stub.fetch('https://ticket.local/mint', { method: 'POST', - body: JSON.stringify({ - userId, - expiresAt: Date.now() + CONNECTION_TICKET_TTL_MS, - }), + body: JSON.stringify(body), }); return res.ok ? ticket : null; } From e8193a12576d916222d01203b022e6d50ad0d302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:42:35 +0200 Subject: [PATCH 192/289] fix(event-service): validate connect ticket query --- services/event-service/src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 6a8d1d9226..0511343e0e 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -2,6 +2,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { useWorkersLogger } from 'workers-tagged-logger'; +import { z } from 'zod'; import type { MiddlewareHandler } from 'hono'; import type { ConnectTicketResponse } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; @@ -16,6 +17,7 @@ const app = new Hono<{ Bindings: Env }>(); const ACCEPTED_WEBSOCKET_PROTOCOL = 'kilo.events.v1'; const CONNECTION_TICKET_TTL_MS = 30_000; const ALLOWED_BROWSER_ORIGINS = ['https://kilo.ai', 'https://app.kilo.ai', 'http://localhost:3000']; +const connectTicketQuerySchema = z.object({ ticket: z.string().min(1) }); app.use( '/connect/*', @@ -94,12 +96,12 @@ app.get('/connect', async c => { return c.json({ error: 'Expected WebSocket upgrade' }, 426); } - const ticket = c.req.query('ticket'); - if (!ticket) { + const query = connectTicketQuerySchema.safeParse({ ticket: c.req.query('ticket') }); + if (!query.success) { return c.json({ error: 'Unauthorized' }, 401); } - const userId = await consumeConnectionTicket(c.env, ticket); + const userId = await consumeConnectionTicket(c.env, query.data.ticket); if (!userId) { return c.json({ error: 'Unauthorized' }, 401); } From cc7ef16b0e6c9d9bd74079be02116fcbd9b48df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:43:07 +0200 Subject: [PATCH 193/289] fix(event-service): schema-check ticket consume responses --- .../event-service/src/do/connection-ticket-do.ts | 7 ++++++- services/event-service/src/index.ts | 16 ++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/services/event-service/src/do/connection-ticket-do.ts b/services/event-service/src/do/connection-ticket-do.ts index b572655ade..0b8c47c1c1 100644 --- a/services/event-service/src/do/connection-ticket-do.ts +++ b/services/event-service/src/do/connection-ticket-do.ts @@ -8,9 +8,13 @@ const ticketStateSchema = z.object({ }); const ticketMintRequestSchema = ticketStateSchema.omit({ consumed: true }); +export const connectionTicketConsumeResponseSchema = z.object({ + userId: z.string().min(1), +}); type TicketState = z.infer; export type TicketMintRequest = z.infer; +export type ConnectionTicketConsumeResponse = z.infer; export class ConnectionTicketDO extends DurableObject { async fetch(request: Request): Promise { @@ -54,6 +58,7 @@ export class ConnectionTicketDO extends DurableObject { return new Response('Unauthorized', { status: 401 }); } - return Response.json({ userId }); + const response = { userId } satisfies ConnectionTicketConsumeResponse; + return Response.json(response); } } diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 0511343e0e..2087f6357c 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -8,7 +8,10 @@ import type { ConnectTicketResponse } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; import { logger } from './util/logger'; -import type { TicketMintRequest } from './do/connection-ticket-do'; +import { + connectionTicketConsumeResponseSchema, + type TicketMintRequest, +} from './do/connection-ticket-do'; export { UserSessionDO } from './do/user-session-do'; export { ConnectionTicketDO } from './do/connection-ticket-do'; @@ -64,15 +67,8 @@ async function consumeConnectionTicket(env: Env, ticket: string): Promise { From 709abf36281d4a91ce12802380e10149ccfcee5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:43:31 +0200 Subject: [PATCH 194/289] fix(kilo-chat): type mark-read success responses --- services/kilo-chat/src/routes/conversations.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/kilo-chat/src/routes/conversations.ts b/services/kilo-chat/src/routes/conversations.ts index f99bca9b16..7a4d03a505 100644 --- a/services/kilo-chat/src/routes/conversations.ts +++ b/services/kilo-chat/src/routes/conversations.ts @@ -4,6 +4,7 @@ import type { CreateConversationResponse, ConversationListResponse, ConversationDetailResponse, + MarkConversationReadResponse, OkResponse, } from '@kilocode/kilo-chat'; import { withDORetry } from '@kilocode/worker-utils'; @@ -199,6 +200,12 @@ export function registerConversationRoutes( return c.json({ error: result.error }, result.code === 'invalid' ? 400 : 403); } - return c.json(result); + const response = { + ok: result.ok, + applied: result.applied, + lastReadAt: result.lastReadAt, + badgeClear: result.badgeClear, + } satisfies MarkConversationReadResponse; + return c.json(response); }); } From 06d625d69b28d07727267e1710cbcd721c3c0a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:44:21 +0200 Subject: [PATCH 195/289] fix(kilo-chat): validate action failure group ids --- .../src/__tests__/bot-messages-routes.test.ts | 19 +++++++++++++++++++ services/kilo-chat/src/routes/handler.ts | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index 38760e7caa..df9e341f39 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -511,6 +511,25 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed expect(res.status).toBe(400); }); + it('returns 400 when groupId path param is invalid', async () => { + const { sandboxId, conversationId, messageId, testEnv, token } = + await setupWithResolvedAction('bot-act-df-bad-group'); + const app = makeBotApp(); + const groupId = 'g'.repeat(201); + + const res = await app.request( + `/bot/v1/sandboxes/${sandboxId}/conversations/${conversationId}/actions/${groupId}/delivery-failed`, + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ messageId }), + }, + testEnv + ); + + expect(res.status).toBe(400); + }); + it('returns 404 when message is unknown', async () => { const { sandboxId, conversationId, testEnv, token } = await setupWithResolvedAction('bot-act-df-missing'); diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index e77f97b9c2..1ac3b46452 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -50,6 +50,7 @@ import { deleteMessageQuerySchema, messageDeliveryFailedRequestSchema, actionDeliveryFailedRequestSchema, + actionGroupIdSchema, decodeConversationCursor, } from '@kilocode/kilo-chat'; @@ -281,8 +282,8 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { const convId = parseConversationId(c); if (!convId.ok) return convId.response; - const groupIdRaw = c.req.param('groupId'); - if (!groupIdRaw) { + const groupId = actionGroupIdSchema.safeParse(c.req.param('groupId')); + if (!groupId.success) { return c.json({ error: 'Invalid groupId' }, 400); } @@ -303,7 +304,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { const { messageId } = parsed.data; const convStub = c.env.CONVERSATION_DO.get(c.env.CONVERSATION_DO.idFromName(convId.data)); - const result = await convStub.revertActionResolution({ messageId, groupId: groupIdRaw }); + const result = await convStub.revertActionResolution({ messageId, groupId: groupId.data }); if (!result.ok) { return c.json({ error: result.error }, 404); } @@ -319,7 +320,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { ctx.sandboxId, ctx.humanMemberIds, 'action.delivery_failed', - { conversationId: convId.data, messageId, groupId: groupIdRaw } + { conversationId: convId.data, messageId, groupId: groupId.data } ); } return c.json({}, 202); From 2575d82adfc585b8319db13237659c9c342fa717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:44:58 +0200 Subject: [PATCH 196/289] fix(kilo-chat): return empty accepted action failures --- services/kilo-chat/src/__tests__/bot-messages-routes.test.ts | 1 + services/kilo-chat/src/routes/handler.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index df9e341f39..28bf26c632 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -491,6 +491,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed testEnv ); expect(second.status).toBe(202); + expect(await second.text()).toBe(''); expect(pushEvent).toHaveBeenCalledOnce(); }); diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index 1ac3b46452..984930feb3 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -309,7 +309,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { return c.json({ error: result.error }, 404); } if (!result.reverted) { - return c.json({}, 202); + return c.body(null, 202); } const ctx = await getConversationContext(c.env, convId.data); From 786e0dbc16cf8eb040057004144db66df853e9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:46:32 +0200 Subject: [PATCH 197/289] fix(kiloclaw): type plugin typing requests --- packages/kilo-chat/src/schemas.ts | 4 ++++ packages/kilo-chat/src/types.ts | 2 ++ packages/kilo-chat/test/schemas.test.ts | 9 +++++++++ services/kiloclaw/plugins/kilo-chat/src/client.ts | 11 ++++++----- .../kiloclaw/plugins/kilo-chat/src/synced/schemas.ts | 4 ++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index c02d643ccf..1445bd3088 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -344,6 +344,10 @@ export const actionDeliveryFailedRequestSchema = z.object({ reason: z.string().max(1000).optional(), }); +export const typingRequestSchema = z.object({ + conversationId: z.string().min(1), +}); + export const createBotConversationRequestSchema = z.object({ title: conversationTitleSchema.optional(), additionalMembers: z.array(z.string().min(1)).max(20).optional(), diff --git a/packages/kilo-chat/src/types.ts b/packages/kilo-chat/src/types.ts index 68ffed5038..84699c0b98 100644 --- a/packages/kilo-chat/src/types.ts +++ b/packages/kilo-chat/src/types.ts @@ -26,6 +26,7 @@ import type { messageListResponseSchema, conversationDetailResponseSchema, okResponseSchema, + typingRequestSchema, addReactionResponseSchema, removeReactionResponseSchema, executeActionRequestSchema, @@ -119,6 +120,7 @@ export type MarkConversationReadRequest = z.infer; export type RenameConversationRequest = z.infer; export type OkResponse = z.infer; +export type TypingRequest = z.infer; export type AddReactionResponse = z.infer; export type RemoveReactionResponse = z.infer; export type ExecuteActionRequest = z.infer; diff --git a/packages/kilo-chat/test/schemas.test.ts b/packages/kilo-chat/test/schemas.test.ts index f18669792a..950f45f495 100644 --- a/packages/kilo-chat/test/schemas.test.ts +++ b/packages/kilo-chat/test/schemas.test.ts @@ -8,6 +8,7 @@ import { editMessageRequestSchema, CONVERSATION_TITLE_MAX_CHARS, MESSAGE_TEXT_MAX_CHARS, + typingRequestSchema, botStatusRecordSchema, botStatusRequestSchema, conversationStatusRecordSchema, @@ -338,3 +339,11 @@ describe('status schemas', () => { ).toBe(true); }); }); + +describe('plugin client request schemas', () => { + it('requires a non-empty conversationId for typing requests', () => { + expect(typingRequestSchema.safeParse({ conversationId: 'c1' }).success).toBe(true); + expect(typingRequestSchema.safeParse({ conversationId: '' }).success).toBe(false); + expect(typingRequestSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/services/kiloclaw/plugins/kilo-chat/src/client.ts b/services/kiloclaw/plugins/kilo-chat/src/client.ts index 4872ea9d67..81d20a04e2 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/client.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/client.ts @@ -19,6 +19,7 @@ import { messageDeliveryFailedRequestSchema, reactionRequestBodySchema, renameConversationRequestSchema, + typingRequestSchema, type botConversationSummarySchema, type contentBlockSchema, type enrichedConversationMemberSchema, @@ -46,7 +47,7 @@ export type EditMessageParams = { messageId: string } & z.input; -export type SendTypingParams = { conversationId: string }; +export type SendTypingParams = z.input; export type ListMessagesParams = { conversationId: string } & z.input< typeof listMessagesQuerySchema @@ -202,9 +203,9 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl `kilo-chat: controller PATCH responded ${response.status}: ${await response.text()}` ); } - const body = editMessageResponseSchema.parse(await response.json()); + const responseBody = editMessageResponseSchema.parse(await response.json()); return { - messageId: body.messageId ?? params.messageId, + messageId: responseBody.messageId ?? params.messageId, stale: false, }; } @@ -227,7 +228,7 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl async function sendTyping(params: SendTypingParams): Promise { const body = { conversationId: params.conversationId, - } satisfies SendTypingParams; + } satisfies z.input; const response = await fetchImpl(`${base}/_kilo/kilo-chat/typing`, { method: 'POST', @@ -245,7 +246,7 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl async function sendTypingStop(params: SendTypingParams): Promise { const body = { conversationId: params.conversationId, - } satisfies SendTypingParams; + } satisfies z.input; const response = await fetchImpl(`${base}/_kilo/kilo-chat/typing/stop`, { method: 'POST', diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts index c02d643ccf..1445bd3088 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts @@ -344,6 +344,10 @@ export const actionDeliveryFailedRequestSchema = z.object({ reason: z.string().max(1000).optional(), }); +export const typingRequestSchema = z.object({ + conversationId: z.string().min(1), +}); + export const createBotConversationRequestSchema = z.object({ title: conversationTitleSchema.optional(), additionalMembers: z.array(z.string().min(1)).max(20).optional(), From 0524e460a725f5a3966fd1db0ad05ab8f7b262f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 01:46:58 +0200 Subject: [PATCH 198/289] fix(kiloclaw): type chat webhook forwards --- services/kiloclaw/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/kiloclaw/src/index.ts b/services/kiloclaw/src/index.ts index 04fd3d82de..5c43a527cd 100644 --- a/services/kiloclaw/src/index.ts +++ b/services/kiloclaw/src/index.ts @@ -15,6 +15,8 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import type { Context, Next } from 'hono'; import { Hono } from 'hono'; import { getCookie, deleteCookie } from 'hono/cookie'; +import type { z } from 'zod'; +import type { chatWebhookSchema } from '@kilocode/kilo-chat'; import type { AppEnv, KiloClawEnv, ChatWebhookPayload } from './types'; import type { SnapshotRestoreMessage } from './schemas/snapshot-restore'; @@ -1077,7 +1079,8 @@ export default class extends WorkerEntrypoint { * stale-online until staleness inference catches up, ~poll interval). */ async deliverChatWebhook(payload: ChatWebhookPayload): Promise { - const { targetBotId, ...webhookPayload } = payload; + const { targetBotId, ...rpcPayload } = payload; + const webhookPayload = rpcPayload satisfies z.infer; const botPrefix = 'bot:kiloclaw:'; if (!targetBotId.startsWith(botPrefix)) { throw new Error(`Invalid targetBotId: ${targetBotId}`); From a9ce2e7ff5150bf60e78ed5c6b9cc90284517645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:01:02 +0200 Subject: [PATCH 199/289] fix(event-service): share connect ticket query schema --- packages/event-service/src/client.ts | 5 +++-- packages/event-service/src/schemas.ts | 6 ++++++ packages/event-service/src/types.ts | 5 +++++ services/event-service/src/index.ts | 3 +-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 7a5908dbf8..1ab948c3cd 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -1,4 +1,4 @@ -import type { ClientMessage, EventServiceConfig } from './types'; +import type { ClientMessage, ConnectTicketQuery, EventServiceConfig } from './types'; import { connectTicketResponseSchema, serverMessageSchema } from './schemas'; const WEBSOCKET_PROTOCOL = 'kilo.events.v1'; @@ -43,7 +43,8 @@ function ticketEndpointFor(wsBase: string): string { function connectUrlFor(wsBase: string, ticket: string): string { const url = baseUrlWithPath(wsBase, 'connect'); - url.searchParams.set('ticket', ticket); + const query = { ticket } satisfies ConnectTicketQuery; + url.searchParams.set('ticket', query.ticket); return url.toString(); } diff --git a/packages/event-service/src/schemas.ts b/packages/event-service/src/schemas.ts index c8976f5f81..a3676d09b1 100644 --- a/packages/event-service/src/schemas.ts +++ b/packages/event-service/src/schemas.ts @@ -41,6 +41,12 @@ export const serverMessageSchema = z.discriminatedUnion('type', [ errorMessageSchema, ]); +// ── HTTP Requests ────────────────────────────────────────────────── + +export const connectTicketQuerySchema = z.object({ + ticket: z.string().min(1), +}); + // ── HTTP Responses ───────────────────────────────────────────────── export const connectTicketResponseSchema = z.object({ diff --git a/packages/event-service/src/types.ts b/packages/event-service/src/types.ts index ecb819d020..3bb378ca3c 100644 --- a/packages/event-service/src/types.ts +++ b/packages/event-service/src/types.ts @@ -6,6 +6,7 @@ import type { errorMessageSchema, eventMessageSchema, serverMessageSchema, + connectTicketQuerySchema, connectTicketResponseSchema, } from './schemas'; @@ -21,6 +22,10 @@ export type EventMessage = z.infer; export type ErrorMessage = z.infer; export type ServerMessage = z.infer; +// ── HTTP Requests ────────────────────────────────────────────────── + +export type ConnectTicketQuery = z.infer; + // ── HTTP Responses ───────────────────────────────────────────────── export type ConnectTicketResponse = z.infer; diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 2087f6357c..20b61a0c86 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -2,9 +2,9 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { useWorkersLogger } from 'workers-tagged-logger'; -import { z } from 'zod'; import type { MiddlewareHandler } from 'hono'; import type { ConnectTicketResponse } from '@kilocode/event-service'; +import { connectTicketQuerySchema } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; import { logger } from './util/logger'; @@ -20,7 +20,6 @@ const app = new Hono<{ Bindings: Env }>(); const ACCEPTED_WEBSOCKET_PROTOCOL = 'kilo.events.v1'; const CONNECTION_TICKET_TTL_MS = 30_000; const ALLOWED_BROWSER_ORIGINS = ['https://kilo.ai', 'https://app.kilo.ai', 'http://localhost:3000']; -const connectTicketQuerySchema = z.object({ ticket: z.string().min(1) }); app.use( '/connect/*', From 15a3536975865dcb2b7fcd6062097756832c44f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:02:12 +0200 Subject: [PATCH 200/289] fix(kilo-chat): return empty bot status nudge acks --- .../kilo-chat/src/__tests__/sandbox-read-routes.test.ts | 8 ++++---- services/kilo-chat/src/services/bot-status-request.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts b/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts index 40b98eec3a..ef471b1ad2 100644 --- a/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts +++ b/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts @@ -178,7 +178,8 @@ describe('POST /v1/sandboxes/:sandboxId/request-bot-status', () => { { method: 'POST' }, makeEnv() ); - expect(res.status).toBe(200); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); const calls = await recordingKiloclaw.__recordedWebhookCalls(); const myCalls = calls.filter(c => c.targetBotId === 'bot:kiloclaw:sandbox-req-fresh'); @@ -206,9 +207,8 @@ describe('POST /v1/sandboxes/:sandboxId/request-bot-status', () => { { method: 'POST' }, testEnv ); - expect(res.status).toBe(200); - const body = await res.json<{ ok: boolean; dedupe?: string }>(); - expect(body.dedupe).toBe('fresh'); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); const calls = await recordingKiloclaw.__recordedWebhookCalls(); const myCalls = calls.filter(c => c.targetBotId === 'bot:kiloclaw:sandbox-req-dedupe'); diff --git a/services/kilo-chat/src/services/bot-status-request.ts b/services/kilo-chat/src/services/bot-status-request.ts index 1ecee5e0a5..e60ebf9890 100644 --- a/services/kilo-chat/src/services/bot-status-request.ts +++ b/services/kilo-chat/src/services/bot-status-request.ts @@ -46,11 +46,11 @@ export async function handleRequestBotStatus(c: HonoCtx): Promise { // The fan-out already pushed the event to all of this user's connections; // skipping here keeps webhook QPS at ~1 per 15s per sandbox regardless of // how many clients are subscribed. - return c.json({ ok: true, dedupe: 'fresh' }); + return c.body(null, 202); } c.executionCtx.waitUntil(triggerBotStatusWebhook(c.env, sandboxId)); - return c.json({ ok: true }); + return c.body(null, 202); } /** From f1b2a209ece11747cd19286addbd628ba1197406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:02:49 +0200 Subject: [PATCH 201/289] fix(kilo-chat): return empty delivery failure acks --- services/kilo-chat/src/__tests__/bot-messages-routes.test.ts | 4 ++++ services/kilo-chat/src/routes/handler.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index 28bf26c632..7f46d6c627 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -258,6 +258,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai testEnv ); expect(res.status).toBe(202); + expect(await res.text()).toBe(''); expect(pushEvent).toHaveBeenCalledOnce(); expect(pushEvent).toHaveBeenCalledWith( 'user-bot-msg-df-ok', @@ -277,6 +278,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai testEnv ); expect(second.status).toBe(202); + expect(await second.text()).toBe(''); expect(pushEvent).not.toHaveBeenCalled(); }); @@ -458,6 +460,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed testEnv ); expect(res.status).toBe(202); + expect(await res.text()).toBe(''); }); it('is idempotent when already unresolved', async () => { @@ -479,6 +482,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed testEnv ); expect(first.status).toBe(202); + expect(await first.text()).toBe(''); expect(pushEvent).toHaveBeenCalledOnce(); const second = await app.request( diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index 984930feb3..618f0d0647 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -273,7 +273,7 @@ export async function handleMessageDeliveryFailed(c: HonoCtx) { if (!result.ok) { return c.json({ error: result.error }, 404); } - return c.json({}, 202); + return c.body(null, 202); } // ─── actionDeliveryFailed (bot-reported) ──────────────────────────────────── @@ -323,7 +323,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { { conversationId: convId.data, messageId, groupId: groupId.data } ); } - return c.json({}, 202); + return c.body(null, 202); } // ─── addReaction ───────────────────────────────────────────────────────────── From 2cd9c1b8dcf09e08854248e764fc85c9a4dcf433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:06:15 +0200 Subject: [PATCH 202/289] fix(web): route claw chat to kilo chat --- .../(app)/claw/chat/[conversationId]/page.tsx | 3 + apps/web/src/app/(app)/claw/chat/layout.tsx | 29 ++++++ apps/web/src/app/(app)/claw/chat/page.tsx | 43 ++++++++- .../src/app/(app)/claw/components/ChatTab.tsx | 12 --- .../(app)/claw/components/ClawChatPage.tsx | 93 ------------------- .../claw/components/chat-redirect.test.ts | 9 -- .../(app)/claw/components/chat-redirect.ts | 3 - .../src/app/(app)/claw/components/index.ts | 1 - .../claw/kilo-chat/[conversationId]/page.tsx | 77 +-------------- .../components/KiloChatConversationPage.tsx | 84 +++++++++++++++++ .../web/src/app/(app)/claw/kilo-chat/page.tsx | 36 +------ .../components/OrganizationAppSidebar.tsx | 11 --- .../(app)/components/PersonalAppSidebar.tsx | 5 - .../[id]/claw/chat/OrgClawChatClient.tsx | 7 -- .../[id]/claw/chat/[conversationId]/page.tsx | 1 + .../organizations/[id]/claw/chat/layout.tsx | 32 +++++++ .../organizations/[id]/claw/chat/page.tsx | 16 +--- .../claw/kilo-chat/[conversationId]/page.tsx | 16 +++- .../[id]/claw/kilo-chat/page.tsx | 16 +++- 19 files changed, 227 insertions(+), 267 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx create mode 100644 apps/web/src/app/(app)/claw/chat/layout.tsx delete mode 100644 apps/web/src/app/(app)/claw/components/ChatTab.tsx delete mode 100644 apps/web/src/app/(app)/claw/components/ClawChatPage.tsx delete mode 100644 apps/web/src/app/(app)/claw/components/chat-redirect.test.ts delete mode 100644 apps/web/src/app/(app)/claw/components/chat-redirect.ts create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx delete mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/chat/OrgClawChatClient.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/chat/[conversationId]/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx diff --git a/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx new file mode 100644 index 0000000000..f33fc2e4ad --- /dev/null +++ b/apps/web/src/app/(app)/claw/chat/[conversationId]/page.tsx @@ -0,0 +1,3 @@ +import { KiloChatConversationPage } from '@/app/(app)/claw/kilo-chat/components/KiloChatConversationPage'; + +export default KiloChatConversationPage; diff --git a/apps/web/src/app/(app)/claw/chat/layout.tsx b/apps/web/src/app/(app)/claw/chat/layout.tsx new file mode 100644 index 0000000000..0dd73d0d13 --- /dev/null +++ b/apps/web/src/app/(app)/claw/chat/layout.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useUser } from '@/hooks/useUser'; +import { useKiloClawStatus } from '@/hooks/useKiloClaw'; +import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; + +export default function ChatRootLayout({ children }: { children: React.ReactNode }) { + const { data: user } = useUser(); + const { data: status, error, isError, isLoading, refetch } = useKiloClawStatus(); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; + + return ( + void refetch()} + assistantName={status?.botName ?? null} + > + {children} + + ); +} diff --git a/apps/web/src/app/(app)/claw/chat/page.tsx b/apps/web/src/app/(app)/claw/chat/page.tsx index 6ea7f89514..0edf7f6f9b 100644 --- a/apps/web/src/app/(app)/claw/chat/page.tsx +++ b/apps/web/src/app/(app)/claw/chat/page.tsx @@ -1,7 +1,44 @@ 'use client'; -import { ClawChatPage } from '../components/ClawChatPage'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { MessagesSquare } from 'lucide-react'; +import { useKiloChatContext } from '@/app/(app)/claw/kilo-chat/components/kiloChatContext'; +import { KiloChatStatusError } from '@/app/(app)/claw/kilo-chat/components/KiloChatStatusError'; +import { kiloChatInstanceRouteDecision } from '@/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard'; -export default function PersonalClawChatPage() { - return ; +export default function ChatIndexPage() { + const router = useRouter(); + const { + instanceErrorMessage, + instanceStatus, + isInstanceError, + isInstanceLoading, + noInstanceRedirect, + onRetryInstanceStatus, + } = useKiloChatContext(); + const routeDecision = kiloChatInstanceRouteDecision({ + instanceStatus, + isInstanceError, + isInstanceLoading, + }); + + useEffect(() => { + if (routeDecision === 'redirect-no-instance') { + router.replace(noInstanceRedirect); + } + }, [noInstanceRedirect, routeDecision, router]); + + if (routeDecision === 'status-error') { + return ; + } + + return ( +
+
+ +

Select a conversation or start a new one

+
+
+ ); } diff --git a/apps/web/src/app/(app)/claw/components/ChatTab.tsx b/apps/web/src/app/(app)/claw/components/ChatTab.tsx deleted file mode 100644 index c2ecfcf5fc..0000000000 --- a/apps/web/src/app/(app)/claw/components/ChatTab.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { buildKiloChatRedirect } from './chat-redirect'; - -export default function ChatTab({ sandboxId, basePath }: { sandboxId: string; basePath: string }) { - const router = useRouter(); - useEffect(() => { - router.replace(buildKiloChatRedirect(basePath, sandboxId)); - }, [router, sandboxId, basePath]); - return null; -} diff --git a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx b/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx deleted file mode 100644 index 59b2e02a98..0000000000 --- a/apps/web/src/app/(app)/claw/components/ClawChatPage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { MessageSquare } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useKiloClawStatus } from '@/hooks/useKiloClaw'; -import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; -import { ClawContextProvider } from './ClawContext'; -import ChatTab from './ChatTab'; -import { ClawConfigServiceBanner } from './ClawConfigServiceBanner'; -import { BillingWrapper } from './billing/BillingWrapper'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { Card, CardContent } from '@/components/ui/card'; - -/** - * Wrapper that polls status and handles loading/error/no-instance states - * before rendering the chat content. - */ -function ClawChatWithStatus({ organizationId }: { organizationId?: string }) { - const router = useRouter(); - const personalStatus = useKiloClawStatus(); - const orgStatus = useOrgKiloClawStatus(organizationId); - const { data: status, isLoading, error } = organizationId ? orgStatus : personalStatus; - - const clawUrl = organizationId ? `/organizations/${organizationId}/claw/new` : '/claw/new'; - - // Redirect to setup when there is no instance. - const shouldRedirect = !isLoading && !error && (!status || status.status === null); - useEffect(() => { - if (shouldRedirect) { - router.replace(clawUrl); - } - }, [shouldRedirect, clawUrl, router]); - - if (isLoading || shouldRedirect) { - return ( - - -

Loading…

-
-
- ); - } - - if (error) { - return ( - - -

- Failed to load status: {error instanceof Error ? error.message : 'Unknown error'} -

-
-
- ); - } - - if (!status || status.status === null) return null; - - const kiloChatBasePath = organizationId - ? `/organizations/${organizationId}/claw/kilo-chat` - : '/claw/kilo-chat'; - const chatContent = ( - <> - - - - - - - - ); - - // Personal context uses BillingWrapper for access-lock dialogs/banners. - if (!organizationId) { - return {chatContent}; - } - - return chatContent; -} - -export function ClawChatPage({ organizationId }: { organizationId?: string }) { - return ( - -
- } - /> - -
-
- ); -} diff --git a/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts b/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts deleted file mode 100644 index 210a049fdf..0000000000 --- a/apps/web/src/app/(app)/claw/components/chat-redirect.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { buildKiloChatRedirect } from './chat-redirect'; - -describe('buildKiloChatRedirect', () => { - it('preserves organization kilo-chat base paths', () => { - expect(buildKiloChatRedirect('/organizations/org-1/claw/kilo-chat', 'sandbox/with space')).toBe( - '/organizations/org-1/claw/kilo-chat?sandboxId=sandbox%2Fwith%20space' - ); - }); -}); diff --git a/apps/web/src/app/(app)/claw/components/chat-redirect.ts b/apps/web/src/app/(app)/claw/components/chat-redirect.ts deleted file mode 100644 index a2259fe648..0000000000 --- a/apps/web/src/app/(app)/claw/components/chat-redirect.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function buildKiloChatRedirect(basePath: string, sandboxId: string): string { - return `${basePath}?sandboxId=${encodeURIComponent(sandboxId)}`; -} diff --git a/apps/web/src/app/(app)/claw/components/index.ts b/apps/web/src/app/(app)/claw/components/index.ts index 7d668b6fe2..7312409b29 100644 --- a/apps/web/src/app/(app)/claw/components/index.ts +++ b/apps/web/src/app/(app)/claw/components/index.ts @@ -7,7 +7,6 @@ export { ClawConfigServiceBannerWithStatus, } from './ClawConfigServiceBanner'; export { ClawHeader } from './ClawHeader'; -export { ClawChatPage } from './ClawChatPage'; export { ClawSettingsPage } from './ClawSettingsPage'; export { DetailTile } from './DetailTile'; export { InstanceTab } from './InstanceTab'; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx index 27908e19b3..d19aeedc2e 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/page.tsx @@ -2,83 +2,14 @@ import { useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { KiloChatApiError } from '@kilocode/kilo-chat'; -import { useKiloChatContext } from '../components/kiloChatContext'; -import { useConversationDetail } from '../hooks/useConversations'; -import { MessageArea } from '../components/MessageArea'; -import { KiloChatStatusError } from '../components/KiloChatStatusError'; -import { conversationRouteDecision } from './conversation-route-guard'; -export default function KiloChatConversationPage() { +export default function LegacyKiloChatConversationPage() { const params = useParams<{ conversationId: string }>(); const router = useRouter(); - const { - kiloChatClient, - leavingConversationId, - basePath, - sandboxId, - isInstanceError, - instanceErrorMessage, - isInstanceLoading, - noInstanceRedirect, - onRetryInstanceStatus, - } = useKiloChatContext(); - const isLeaving = leavingConversationId === params.conversationId; - const conversationDetail = useConversationDetail( - kiloChatClient, - isLeaving || isInstanceError ? null : params.conversationId - ); - const routeDecision = conversationRouteDecision({ - conversationMembers: conversationDetail.data?.members, - isInstanceError, - isInstanceLoading, - isLeaving, - routeSandboxId: sandboxId, - }); useEffect(() => { - if (routeDecision === 'redirect-no-instance') { - router.replace(noInstanceRedirect); - return; - } - if (routeDecision === 'not-found') { - toast.error('Conversation not found'); - router.replace(basePath); - return; - } - if (conversationDetail.isError && !isLeaving) { - const status = - conversationDetail.error instanceof KiloChatApiError - ? conversationDetail.error.status - : undefined; - const message = - status === 400 || status === 403 || status === 404 - ? 'Conversation not found' - : 'Failed to load conversation'; - toast.error(message); - router.replace(basePath); - } - }, [ - conversationDetail.isError, - conversationDetail.error, - isLeaving, - router, - basePath, - noInstanceRedirect, - routeDecision, - ]); + router.replace(`/claw/chat/${params.conversationId}`); + }, [params.conversationId, router]); - if (isLeaving || routeDecision !== 'ready') { - if (routeDecision === 'status-error') { - return ; - } - return null; - } - - if (conversationDetail.isError) { - return null; - } - - return ; + return null; } diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx new file mode 100644 index 0000000000..cb064407ab --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { KiloChatApiError } from '@kilocode/kilo-chat'; +import { useKiloChatContext } from './kiloChatContext'; +import { useConversationDetail } from '../hooks/useConversations'; +import { MessageArea } from './MessageArea'; +import { KiloChatStatusError } from './KiloChatStatusError'; +import { conversationRouteDecision } from '../[conversationId]/conversation-route-guard'; + +export function KiloChatConversationPage() { + const params = useParams<{ conversationId: string }>(); + const router = useRouter(); + const { + kiloChatClient, + leavingConversationId, + basePath, + sandboxId, + isInstanceError, + instanceErrorMessage, + isInstanceLoading, + noInstanceRedirect, + onRetryInstanceStatus, + } = useKiloChatContext(); + const isLeaving = leavingConversationId === params.conversationId; + const conversationDetail = useConversationDetail( + kiloChatClient, + isLeaving || isInstanceError ? null : params.conversationId + ); + const routeDecision = conversationRouteDecision({ + conversationMembers: conversationDetail.data?.members, + isInstanceError, + isInstanceLoading, + isLeaving, + routeSandboxId: sandboxId, + }); + + useEffect(() => { + if (routeDecision === 'redirect-no-instance') { + router.replace(noInstanceRedirect); + return; + } + if (routeDecision === 'not-found') { + toast.error('Conversation not found'); + router.replace(basePath); + return; + } + if (conversationDetail.isError && !isLeaving) { + const status = + conversationDetail.error instanceof KiloChatApiError + ? conversationDetail.error.status + : undefined; + const message = + status === 400 || status === 403 || status === 404 + ? 'Conversation not found' + : 'Failed to load conversation'; + toast.error(message); + router.replace(basePath); + } + }, [ + conversationDetail.isError, + conversationDetail.error, + isLeaving, + router, + basePath, + noInstanceRedirect, + routeDecision, + ]); + + if (isLeaving || routeDecision !== 'ready') { + if (routeDecision === 'status-error') { + return ; + } + return null; + } + + if (conversationDetail.isError) { + return null; + } + + return ; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/page.tsx b/apps/web/src/app/(app)/claw/kilo-chat/page.tsx index 388e43c36e..1293f43ac0 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/page.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/page.tsx @@ -2,43 +2,13 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { MessagesSquare } from 'lucide-react'; -import { useKiloChatContext } from './components/kiloChatContext'; -import { KiloChatStatusError } from './components/KiloChatStatusError'; -import { kiloChatInstanceRouteDecision } from './[conversationId]/conversation-route-guard'; export default function KiloChatIndexPage() { const router = useRouter(); - const { - instanceErrorMessage, - instanceStatus, - isInstanceError, - isInstanceLoading, - noInstanceRedirect, - onRetryInstanceStatus, - } = useKiloChatContext(); - const routeDecision = kiloChatInstanceRouteDecision({ - instanceStatus, - isInstanceError, - isInstanceLoading, - }); useEffect(() => { - if (routeDecision === 'redirect-no-instance') { - router.replace(noInstanceRedirect); - } - }, [noInstanceRedirect, routeDecision, router]); + router.replace('/claw/chat'); + }, [router]); - if (routeDecision === 'status-error') { - return ; - } - - return ( -
-
- -

Select a conversation or start a new one

-
-
- ); + return null; } diff --git a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx index 6d02aa4d51..c56c8cdadd 100644 --- a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx @@ -25,7 +25,6 @@ import { Webhook, Settings, MessageSquare, - MessagesSquare, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -58,7 +57,6 @@ export default function OrganizationAppSidebar({ // Feature flags const isAutoTriageFeatureEnabled = useFeatureFlagEnabled('auto-triage-feature'); - const isKiloChatEnabled = useFeatureFlagEnabled('kilo-chat-feature'); const isDevelopment = process.env.NODE_ENV === 'development'; // Get current organization role and data @@ -142,15 +140,6 @@ export default function OrganizationAppSidebar({ icon: MessageSquare, url: `/organizations/${organizationId}/claw/chat`, }, - ...(isKiloChatEnabled || isDevelopment - ? [ - { - title: 'Kilo Chat', - icon: MessagesSquare, - url: `/organizations/${organizationId}/claw/kilo-chat`, - }, - ] - : []), { title: 'Settings', icon: Settings, diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index a51bb7d261..ccca3f1db8 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -29,7 +29,6 @@ import { Settings, CreditCard, MessageSquare, - MessagesSquare, Sparkles, ChevronLeft, ChevronRight, @@ -51,7 +50,6 @@ export default function PersonalAppSidebar(props: React.ComponentProps; -} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/chat/[conversationId]/page.tsx new file mode 100644 index 0000000000..b67695334f --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/claw/chat/[conversationId]/page.tsx @@ -0,0 +1 @@ +export { default } from '@/app/(app)/claw/chat/[conversationId]/page'; diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx new file mode 100644 index 0000000000..c8edac14d7 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useUser } from '@/hooks/useUser'; +import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; +import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; + +export default function OrgChatRootLayout({ children }: { children: React.ReactNode }) { + const params = useParams<{ id: string }>(); + const organizationId = params.id; + const { data: user } = useUser(); + const { data: status, error, isError, isLoading, refetch } = useOrgKiloClawStatus(organizationId); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; + + return ( + void refetch()} + assistantName={status?.botName ?? null} + > + {children} + + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/chat/page.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/chat/page.tsx index 123aec744c..aea163057f 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/chat/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/chat/page.tsx @@ -1,15 +1 @@ -import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; -import { OrgClawChatClient } from './OrgClawChatClient'; - -type OrgClawChatPageProps = { - params: Promise<{ id: string }>; -}; - -export default async function OrgClawChatPage({ params }: OrgClawChatPageProps) { - return ( - } - /> - ); -} +export { default } from '@/app/(app)/claw/chat/page'; diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/[conversationId]/page.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/[conversationId]/page.tsx index 6138f97179..2c67238a41 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/[conversationId]/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/[conversationId]/page.tsx @@ -1 +1,15 @@ -export { default } from '@/app/(app)/claw/kilo-chat/[conversationId]/page'; +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +export default function OrgLegacyKiloChatConversationPage() { + const params = useParams<{ id: string; conversationId: string }>(); + const router = useRouter(); + + useEffect(() => { + router.replace(`/organizations/${params.id}/claw/chat/${params.conversationId}`); + }, [params.conversationId, params.id, router]); + + return null; +} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/page.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/page.tsx index 15b698aaab..8a0bb7c6d8 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/page.tsx @@ -1 +1,15 @@ -export { default } from '@/app/(app)/claw/kilo-chat/page'; +'use client'; + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +export default function OrgLegacyKiloChatIndexPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + + useEffect(() => { + router.replace(`/organizations/${params.id}/claw/chat`); + }, [params.id, router]); + + return null; +} From 9e1782cb6cb7f25d1964980b4293d53b32204130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:16:22 +0200 Subject: [PATCH 203/289] fix(dev-env): sync local secrets store bindings --- dev/local/env-sync/index.ts | 24 ++++++-- dev/local/env-sync/output.ts | 2 +- dev/local/env-sync/plan.test.ts | 94 ++++++++++++++++++++++++++++++++ dev/local/env-sync/plan.ts | 97 +++++++++++++++++++++++++++------ dev/local/env-sync/types.ts | 2 +- 5 files changed, 195 insertions(+), 24 deletions(-) diff --git a/dev/local/env-sync/index.ts b/dev/local/env-sync/index.ts index 1ca841cfb9..1da6a6c409 100644 --- a/dev/local/env-sync/index.ts +++ b/dev/local/env-sync/index.ts @@ -52,14 +52,19 @@ async function syncEnvVars(options: { } const hasChanges = planHasChanges(plan); - const totalMissing = plan.devVarsChanges.reduce((sum, c) => sum + c.missingValues.length, 0); + const totalMissing = + plan.devVarsChanges.reduce((sum, c) => sum + c.missingValues.length, 0) + + plan.secretStoreWarnings.reduce((sum, c) => sum + c.bindings.length, 0); displayPlan(plan); if (check) { return { - ok: !hasChanges, - changed: plan.devVarsChanges.length + plan.envLocalAutoCreates.length, + ok: !hasChanges && plan.secretStoreWarnings.length === 0, + changed: + plan.devVarsChanges.length + + plan.envLocalAutoCreates.length + + plan.secretStoreAutoCreates.length, missing: totalMissing, }; } @@ -79,7 +84,10 @@ async function syncEnvVars(options: { return { ok: true, - changed: plan.devVarsChanges.length + plan.envLocalAutoCreates.length, + changed: + plan.devVarsChanges.length + + plan.envLocalAutoCreates.length + + plan.secretStoreAutoCreates.length, missing: totalMissing, }; } @@ -92,14 +100,18 @@ async function checkEnvVars(repoRoot: string, targets?: string[]): Promise sum + c.missingValues.length, 0); + const totalMissing = + plan.devVarsChanges.reduce((sum, c) => sum + c.missingValues.length, 0) + + plan.secretStoreWarnings.reduce((sum, c) => sum + c.bindings.length, 0); const workerCount = findDevVarsExamples(repoRoot).length; return { ok: !plan.devVarsChanges.some(c => c.isNew || c.keyChanges.length > 0) && plan.envDevLocalChanges.length === 0 && - plan.envLocalAutoCreates.length === 0, + plan.envLocalAutoCreates.length === 0 && + plan.secretStoreAutoCreates.length === 0 && + plan.secretStoreWarnings.length === 0, envLocalExists: true, missing: totalMissing, workerCount, diff --git a/dev/local/env-sync/output.ts b/dev/local/env-sync/output.ts index d0d54b63fa..0b586f61ee 100644 --- a/dev/local/env-sync/output.ts +++ b/dev/local/env-sync/output.ts @@ -98,7 +98,7 @@ function displayPlan(plan: EnvSyncPlan): void { // Secrets store auto-creates for (const create of group.autoCreates) { console.log( - ` ${GREEN}⊕${RESET} secret: ${create.binding.secret_name} ${DIM}@from ${create.envLocalKey}${RESET}` + ` ${GREEN}⊕${RESET} secret: ${create.binding.secret_name} ${DIM}@from ${create.sourceKey}${RESET}` ); } diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 46278c7bf4..df85537ad1 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -57,6 +57,21 @@ function computeCloudAgentNextPlan(root: string) { return plan; } +function withFakePnpm(output: string, fn: () => void): void { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-sync-bin-')); + const oldPath = process.env.PATH; + try { + const pnpmPath = path.join(binDir, 'pnpm'); + fs.writeFileSync(pnpmPath, `#!/bin/sh\nprintf '%s' ${JSON.stringify(output)}\n`, 'utf-8'); + fs.chmodSync(pnpmPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`; + fn(); + } finally { + process.env.PATH = oldPath; + fs.rmSync(binDir, { recursive: true, force: true }); + } +} + test('treats selected wrangler environment vars as satisfied without copying them', () => { const repo = createCloudAgentNextRepo({ wranglerJsonc: `{ @@ -137,6 +152,85 @@ test('writes example defaults to .dev.vars when they override wrangler vars', () } }); +test('auto-creates event-service NEXTAUTH Secrets Store binding from .env.local', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/event-service/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/event-service/wrangler.jsonc': `{ + "secrets_store_secrets": [ + { + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + } + ] + }`, + }); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['event-service'])); + assert.equal(plan.missingEnvLocal, false); + assert.deepEqual(plan.secretStoreWarnings, []); + assert.equal(plan.secretStoreAutoCreates.length, 1); + assert.deepEqual(plan.secretStoreAutoCreates[0], { + workerDir: 'services/event-service', + binding: { + binding: 'NEXTAUTH_SECRET', + store_id: 'store-id', + secret_name: 'NEXTAUTH_SECRET_PROD', + }, + sourceKey: 'NEXTAUTH_SECRET', + value: 'local-nextauth-secret', + }); + }); + } finally { + repo.cleanup(); + } +}); + +test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev vars', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/kiloclaw/.dev.vars.example': 'GATEWAY_TOKEN_SECRET=dev-gateway-secret-kiloclaw\n', + 'services/kilo-chat/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/kilo-chat/wrangler.jsonc': `{ + "secrets_store_secrets": [ + { + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + }, + { + "binding": "GATEWAY_TOKEN_SECRET", + "store_id": "store-id", + "secret_name": "GATEWAY_TOKEN_SECRET" + } + ] + }`, + }); + try { + withFakePnpm('NEXTAUTH_SECRET_PROD\n', () => { + const plan = computePlan(repo.root, new Set(['kilo-chat'])); + assert.equal(plan.missingEnvLocal, false); + assert.deepEqual(plan.secretStoreWarnings, []); + assert.deepEqual(plan.secretStoreAutoCreates, [ + { + workerDir: 'services/kilo-chat', + binding: { + binding: 'GATEWAY_TOKEN_SECRET', + store_id: 'store-id', + secret_name: 'GATEWAY_TOKEN_SECRET', + }, + sourceKey: 'services/kiloclaw/.dev.vars.example:GATEWAY_TOKEN_SECRET', + value: 'dev-gateway-secret-kiloclaw', + }, + ]); + }); + } finally { + repo.cleanup(); + } +}); + test('keeps .env.local values ahead of wrangler vars for local overrides', () => { const repo = createCloudAgentNextRepo({ envLocal: 'R2_ATTACHMENTS_BUCKET=local-attachments\n', diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index c003805303..59828cbf4f 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -254,6 +254,66 @@ function listLocalStoreSecrets(repoRoot: string, workerDir: string, storeId: str return result.status === 0 ? result.stdout : ''; } +function resolveSecretStoreSource( + secretName: string, + envLocal: Map, + localSecretSources: Map +): { sourceKey: string; value: string } | undefined { + const baseKey = secretName.replace(/_(PROD|DEV)$/, ''); + const envLocalValue = envLocal.get(baseKey); + if (envLocalValue) { + return { sourceKey: baseKey, value: envLocalValue }; + } + + return localSecretSources.get(baseKey); +} + +function collectLocalSecretSources( + repoRoot: string, + workerDirs: string[], + envLocal: Map, + lanIp: string | undefined, + dirUsesLanIp: Map +): Map { + const sources = new Map(); + + for (const workerDir of workerDirs) { + const examplePath = path.join(repoRoot, workerDir, '.dev.vars.example'); + const serviceUsesLanIp = dirUsesLanIp.get(workerDir) ?? false; + if (fs.existsSync(examplePath)) { + const entries = parseExampleFile(fs.readFileSync(examplePath, 'utf-8')); + for (const entry of entries) { + const { value } = resolveAnnotatedValue( + entry.key, + entry, + envLocal, + lanIp, + serviceUsesLanIp + ); + if (value && !sources.has(entry.key)) { + sources.set(entry.key, { + sourceKey: `${workerDir}/.dev.vars.example:${entry.key}`, + value, + }); + } + } + } + + const devVarsPath = path.join(repoRoot, workerDir, '.dev.vars'); + const localVars = readEnvFile(devVarsPath); + for (const [key, value] of localVars) { + if (value) { + sources.set(key, { + sourceKey: `${workerDir}/.dev.vars:${key}`, + value, + }); + } + } + } + + return sources; +} + // --------------------------------------------------------------------------- // Plan computation // --------------------------------------------------------------------------- @@ -278,6 +338,22 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const envLocal = parseEnvFile(fs.readFileSync(envLocalPath, 'utf-8')); const allWorkerDirs = findDevVarsExamples(repoRoot); + // Build dir→useLanIp lookup + const dirUsesLanIp = new Map(); + for (const [, svc] of services) { + if (svc.useLanIp) { + dirUsesLanIp.set(svc.dir, true); + } + } + + const localSecretSources = collectLocalSecretSources( + repoRoot, + allWorkerDirs, + envLocal, + lanIp, + dirUsesLanIp + ); + // When filtering by service, only process dirs belonging to targeted services let workerDirs: string[]; if (serviceFilter) { @@ -291,14 +367,6 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan workerDirs = allWorkerDirs; } - // Build dir→useLanIp lookup - const dirUsesLanIp = new Map(); - for (const [, svc] of services) { - if (svc.useLanIp) { - dirUsesLanIp.set(svc.dir, true); - } - } - // --- .dev.vars changes --- const devVarsChanges: DevVarsFileChange[] = []; const envLocalAutoCreates: EnvLocalAutoCreate[] = []; @@ -488,18 +556,15 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan continue; // Secret exists, nothing to do } - // Try to map secret name to .env.local key via naming convention - // Strip _PROD or _DEV suffix to get base key - const envLocalKey = b.secret_name.replace(/_(PROD|DEV)$/, ''); - const value = envLocal.get(envLocalKey); + const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); - if (value) { - // Can auto-create from .env.local + if (source) { + // Can auto-create from .env.local or another local worker's dev vars. secretStoreAutoCreates.push({ workerDir: svc.dir, binding: b, - envLocalKey, - value, + sourceKey: source.sourceKey, + value: source.value, }); } else { // Missing and no source value - warn diff --git a/dev/local/env-sync/types.ts b/dev/local/env-sync/types.ts index 53521a1f23..db056ac661 100644 --- a/dev/local/env-sync/types.ts +++ b/dev/local/env-sync/types.ts @@ -48,7 +48,7 @@ type ConsistencyWarning = { type SecretStoreAutoCreate = { workerDir: string; binding: SecretStoreBinding; - envLocalKey: string; + sourceKey: string; value: string; }; From 0cf73609446a812ebf8d22493508b2eb475be459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 02:37:11 +0200 Subject: [PATCH 204/289] fix(event-service): align local auth env --- services/event-service/.dev.vars.example | 2 ++ .../event-service/src/__tests__/connection-ticket.test.ts | 6 +++++- services/kilo-chat/.dev.vars.example | 2 ++ services/notifications/.dev.vars.example | 2 ++ 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 services/event-service/.dev.vars.example create mode 100644 services/kilo-chat/.dev.vars.example create mode 100644 services/notifications/.dev.vars.example diff --git a/services/event-service/.dev.vars.example b/services/event-service/.dev.vars.example new file mode 100644 index 0000000000..71e6cb0279 --- /dev/null +++ b/services/event-service/.dev.vars.example @@ -0,0 +1,2 @@ +# Local worker auth must match Kilo JWTs minted by the Next.js dev server. +WORKER_ENV=development diff --git a/services/event-service/src/__tests__/connection-ticket.test.ts b/services/event-service/src/__tests__/connection-ticket.test.ts index 72ebab7a9e..69b13287ed 100644 --- a/services/event-service/src/__tests__/connection-ticket.test.ts +++ b/services/event-service/src/__tests__/connection-ticket.test.ts @@ -9,13 +9,17 @@ function ticketNamespace(): DurableObjectNamespace { return (env as unknown as { CONNECTION_TICKET_DO: DurableObjectNamespace }).CONNECTION_TICKET_DO; } +function workerEnv(): string { + return (env as unknown as { WORKER_ENV: string }).WORKER_ENV; +} + async function chatToken(userId: string): Promise { const { token } = await signKiloToken({ userId, pepper: null, secret: TEST_JWT_SECRET, expiresInSeconds: 3600, - env: 'production', + env: workerEnv(), extra: { tokenSource: 'kilo-chat' }, }); return token; diff --git a/services/kilo-chat/.dev.vars.example b/services/kilo-chat/.dev.vars.example new file mode 100644 index 0000000000..71e6cb0279 --- /dev/null +++ b/services/kilo-chat/.dev.vars.example @@ -0,0 +1,2 @@ +# Local worker auth must match Kilo JWTs minted by the Next.js dev server. +WORKER_ENV=development diff --git a/services/notifications/.dev.vars.example b/services/notifications/.dev.vars.example new file mode 100644 index 0000000000..71e6cb0279 --- /dev/null +++ b/services/notifications/.dev.vars.example @@ -0,0 +1,2 @@ +# Local worker auth must match Kilo JWTs minted by the Next.js dev server. +WORKER_ENV=development From 960094fc973bd85ba7b8623a9289bef636a7bc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:04:40 +0200 Subject: [PATCH 205/289] refactor(event-service): use RPC for connection tickets --- .../src/__tests__/connection-ticket.test.ts | 77 ++++++++++++++++--- .../src/do/connection-ticket-do.ts | 49 +++++------- services/event-service/src/index.ts | 26 +++---- 3 files changed, 97 insertions(+), 55 deletions(-) diff --git a/services/event-service/src/__tests__/connection-ticket.test.ts b/services/event-service/src/__tests__/connection-ticket.test.ts index 69b13287ed..a2ed530b80 100644 --- a/services/event-service/src/__tests__/connection-ticket.test.ts +++ b/services/event-service/src/__tests__/connection-ticket.test.ts @@ -1,12 +1,18 @@ -import { env, SELF } from 'cloudflare:test'; +import { env, runDurableObjectAlarm, runInDurableObject, SELF } from 'cloudflare:test'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { signKiloToken } from '@kilocode/worker-utils'; +import type { ConnectionTicketDO } from '../do/connection-ticket-do'; const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; const ACCEPTED_PROTOCOL = 'kilo.events.v1'; -function ticketNamespace(): DurableObjectNamespace { - return (env as unknown as { CONNECTION_TICKET_DO: DurableObjectNamespace }).CONNECTION_TICKET_DO; +function ticketNamespace(): DurableObjectNamespace { + return (env as unknown as { CONNECTION_TICKET_DO: DurableObjectNamespace }) + .CONNECTION_TICKET_DO; +} + +function ticketStub(ticket: string): DurableObjectStub { + return ticketNamespace().get(ticketNamespace().idFromName(ticket)); } function workerEnv(): string { @@ -94,17 +100,68 @@ describe('event-service WebSocket connection tickets', () => { it('rejects stale tickets', async () => { const ticket = crypto.randomUUID(); - const stub = ticketNamespace().get(ticketNamespace().idFromName(ticket)); - await stub.fetch('https://ticket.local/mint', { - method: 'POST', - body: JSON.stringify({ - userId: 'user-ticket-stale', - expiresAt: Date.now() - 1, - }), + await ticketStub(ticket).mint({ + userId: 'user-ticket-stale', + expiresAt: Date.now() - 1, }); const res = await connect(ticket); expect(res.status).toBe(401); }); + + it('deletes ticket storage and alarm after a successful consume', async () => { + const ticket = crypto.randomUUID(); + const stub = ticketStub(ticket); + const expiresAt = Date.now() + 30_000; + + await stub.mint({ userId: 'user-ticket-consume-cleanup', expiresAt }); + await expect( + runInDurableObject(stub, async (_instance: ConnectionTicketDO, state) => ({ + ticket: await state.storage.get('ticket'), + alarm: await state.storage.getAlarm(), + })) + ).resolves.toEqual({ + ticket: { userId: 'user-ticket-consume-cleanup', expiresAt }, + alarm: expiresAt, + }); + + await expect(stub.consume()).resolves.toEqual({ userId: 'user-ticket-consume-cleanup' }); + + await expect( + runInDurableObject(stub, async (_instance: ConnectionTicketDO, state) => ({ + ticket: await state.storage.get('ticket'), + alarm: await state.storage.getAlarm(), + })) + ).resolves.toEqual({ + ticket: undefined, + alarm: null, + }); + }); + + it('deletes unconsumed expired ticket storage when the alarm runs', async () => { + const ticket = crypto.randomUUID(); + const stub = ticketStub(ticket); + const expiresAt = Date.now() + 30_000; + + await stub.mint({ userId: 'user-ticket-alarm-cleanup', expiresAt }); + await runInDurableObject(stub, async (_instance: ConnectionTicketDO, state) => { + await state.storage.put('ticket', { + userId: 'user-ticket-alarm-cleanup', + expiresAt: Date.now() - 1, + }); + }); + + await expect(runDurableObjectAlarm(stub)).resolves.toBe(true); + + await expect( + runInDurableObject(stub, async (_instance: ConnectionTicketDO, state) => ({ + ticket: await state.storage.get('ticket'), + alarm: await state.storage.getAlarm(), + })) + ).resolves.toEqual({ + ticket: undefined, + alarm: null, + }); + }); }); diff --git a/services/event-service/src/do/connection-ticket-do.ts b/services/event-service/src/do/connection-ticket-do.ts index 0b8c47c1c1..928b954716 100644 --- a/services/event-service/src/do/connection-ticket-do.ts +++ b/services/event-service/src/do/connection-ticket-do.ts @@ -4,61 +4,48 @@ import { z } from 'zod'; const ticketStateSchema = z.object({ userId: z.string().min(1), expiresAt: z.number().int(), - consumed: z.boolean().optional(), }); -const ticketMintRequestSchema = ticketStateSchema.omit({ consumed: true }); export const connectionTicketConsumeResponseSchema = z.object({ userId: z.string().min(1), }); type TicketState = z.infer; -export type TicketMintRequest = z.infer; +export type TicketMintRequest = TicketState; export type ConnectionTicketConsumeResponse = z.infer; export class ConnectionTicketDO extends DurableObject { - async fetch(request: Request): Promise { - const url = new URL(request.url); - if (request.method === 'POST' && url.pathname === '/mint') { - return this.mint(request); - } - if (request.method === 'POST' && url.pathname === '/consume') { - return this.consume(); - } - return new Response('Not found', { status: 404 }); + async mint(input: TicketMintRequest): Promise { + await this.ctx.storage.put('ticket', input); + await this.ctx.storage.setAlarm(input.expiresAt); } - private async mint(request: Request): Promise { - const body: unknown = await request.json().catch(() => null); - // This DO still exposes an HTTP-shaped fetch endpoint, so validate the - // serialized JSON even though the caller is internal service code. - const parsed = ticketMintRequestSchema.safeParse(body); - if (!parsed.success) { - return new Response('Invalid ticket', { status: 400 }); - } - - await this.ctx.storage.put('ticket', { ...parsed.data, consumed: false }); - return new Response(null, { status: 204 }); - } - - private async consume(): Promise { + async consume(): Promise { const userId = await this.ctx.storage.transaction(async txn => { const stored = await txn.get('ticket'); const parsed = ticketStateSchema.safeParse(stored); - if (!parsed.success || parsed.data.consumed || parsed.data.expiresAt <= Date.now()) { + if (!parsed.success || parsed.data.expiresAt <= Date.now()) { await txn.delete('ticket'); return null; } - await txn.put('ticket', { ...parsed.data, consumed: true }); + await txn.delete('ticket'); return parsed.data.userId; }); if (!userId) { - return new Response('Unauthorized', { status: 401 }); + return null; } - const response = { userId } satisfies ConnectionTicketConsumeResponse; - return Response.json(response); + await this.ctx.storage.deleteAlarm(); + return { userId } satisfies ConnectionTicketConsumeResponse; + } + + async alarm(): Promise { + const stored = await this.ctx.storage.get('ticket'); + const parsed = ticketStateSchema.safeParse(stored); + if (!parsed.success || parsed.data.expiresAt <= Date.now()) { + await this.ctx.storage.delete('ticket'); + } } } diff --git a/services/event-service/src/index.ts b/services/event-service/src/index.ts index 20b61a0c86..3cf2f92dcc 100644 --- a/services/event-service/src/index.ts +++ b/services/event-service/src/index.ts @@ -8,10 +8,7 @@ import { connectTicketQuerySchema } from '@kilocode/event-service'; import { extractBearerToken } from '@kilocode/worker-utils'; import { authenticateToken } from './auth'; import { logger } from './util/logger'; -import { - connectionTicketConsumeResponseSchema, - type TicketMintRequest, -} from './do/connection-ticket-do'; +import { type TicketMintRequest } from './do/connection-ticket-do'; export { UserSessionDO } from './do/user-session-do'; export { ConnectionTicketDO } from './do/connection-ticket-do'; @@ -54,20 +51,21 @@ async function mintConnectionTicket(env: Env, userId: string): Promise { const stub = env.CONNECTION_TICKET_DO.get(env.CONNECTION_TICKET_DO.idFromName(ticket)); - const res = await stub.fetch('https://ticket.local/consume', { method: 'POST' }); - if (!res.ok) return null; - const body: unknown = await res.json(); - const parsed = connectionTicketConsumeResponseSchema.safeParse(body); - return parsed.success ? parsed.data.userId : null; + try { + return (await stub.consume())?.userId ?? null; + } catch { + return null; + } } app.post('/connect-ticket', async c => { From 12b5b4c1f3deb0a5721dad6ae69639d9e84c92b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:09:44 +0200 Subject: [PATCH 206/289] fix(event-service): reconnect pre-open socket failures --- .../src/__tests__/client.test.ts | 32 +++++++------------ packages/event-service/src/client.ts | 22 ++++--------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/packages/event-service/src/__tests__/client.test.ts b/packages/event-service/src/__tests__/client.test.ts index 2405c801f1..f5a0212f62 100644 --- a/packages/event-service/src/__tests__/client.test.ts +++ b/packages/event-service/src/__tests__/client.test.ts @@ -228,7 +228,7 @@ describe('EventServiceClient', () => { expect(allMockWs).toHaveLength(2); }); - it('refreshes a stale token once after a pre-open rejection and reconnects', async () => { + it('reconnects after a pre-open error without refreshing auth', async () => { vi.useFakeTimers(); try { const tokens = ['stale.token.sig', 'fresh.token.sig']; @@ -264,7 +264,7 @@ describe('EventServiceClient', () => { await client.connect(); - expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized).not.toHaveBeenCalled(); expect(client.isConnected()).toBe(false); await vi.advanceTimersByTimeAsync(2_000); @@ -282,25 +282,15 @@ describe('EventServiceClient', () => { } }); - it('stops after an explicit unauthorized stop decision', async () => { + it('stops after an explicit unauthorized stop decision from connect-ticket', async () => { vi.useFakeTimers(); try { const stopAuth = (): 'stop' => 'stop'; const onUnauthorized = vi.fn(stopAuth); - const WebSocketMock = function (url: string, protocols?: string | string[]) { - lastMockWs = new MockWebSocket(url, protocols); - allMockWs.push(lastMockWs); - lastMockWs.readyState = 0; // CONNECTING - void Promise.resolve().then(() => { - lastMockWs.triggerError(); - lastMockWs.triggerClose(); - }); - return lastMockWs; - }; - WebSocketMock.OPEN = 1; - WebSocketMock.CLOSING = 2; - WebSocketMock.CLOSED = 3; - vi.stubGlobal('WebSocket', WebSocketMock); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })) + ); const client = new EventServiceClient({ url: 'ws://localhost:8080', @@ -311,17 +301,17 @@ describe('EventServiceClient', () => { await client.connect(); expect(onUnauthorized).toHaveBeenCalledTimes(1); - expect(allMockWs).toHaveLength(1); + expect(allMockWs).toHaveLength(0); expect(client.isConnected()).toBe(false); await vi.advanceTimersByTimeAsync(60_000); - expect(allMockWs).toHaveLength(1); + expect(allMockWs).toHaveLength(0); } finally { vi.useRealTimers(); } }); - it('keeps reconnecting after configured auth recovery is consumed by pre-open failures', async () => { + it('keeps reconnecting after repeated pre-open failures', async () => { vi.useFakeTimers(); const reconnectDelay = vi.spyOn(Math, 'random').mockReturnValue(1); try { @@ -365,7 +355,7 @@ describe('EventServiceClient', () => { await vi.advanceTimersByTimeAsync(4_000); expect(allMockWs).toHaveLength(3); expect(client.isConnected()).toBe(true); - expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized).not.toHaveBeenCalled(); expect(allMockWs[2]?.sent.map(s => JSON.parse(s) as unknown)).toContainEqual({ type: 'context.subscribe', contexts: ['room:configured'], diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 1ab948c3cd..e6b506dd7b 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -5,10 +5,9 @@ const WEBSOCKET_PROTOCOL = 'kilo.events.v1'; /** * Thrown (and surfaced via {@link EventServiceConfig.onUnauthorized}) when the - * Event Service rejects the WebSocket upgrade with 401/403. Browsers do not - * expose the HTTP status of a failed WebSocket handshake, so the client - * treats any pre-open 'error' event as a potential auth failure and relies on - * the callback to trigger token refresh/sign-out. + * Event Service rejects connection-ticket minting with 401/403. Browsers do not + * expose the HTTP status of a failed WebSocket handshake, so pre-open socket + * errors are treated as generic reconnectable failures. */ export class WebSocketAuthError extends Error { constructor(message = 'WebSocket authentication failed') { @@ -86,7 +85,6 @@ export class EventServiceClient { private pingTimer: ReturnType | null = null; private handshakeTimer: ReturnType | null = null; private abortHandshake: ((err: Error) => void) | null = null; - private suppressNextCloseReconnect = false; constructor(config: EventServiceConfig) { this.url = config.url; @@ -216,10 +214,6 @@ export class EventServiceClient { this.connected = false; this.stopPing(); this.clearHandshakeTimer(); - if (this.suppressNextCloseReconnect) { - this.suppressNextCloseReconnect = false; - return; - } if (!this.destroyed) { this.scheduleReconnect(); } @@ -228,13 +222,11 @@ export class EventServiceClient { ws.addEventListener('error', () => { if (this.ws !== ws) return; // error is always followed by close, so we only need to reject the - // connect promise here if we never opened. The browser does not - // expose the HTTP status of a failed upgrade, so treat pre-open - // errors as potential auth failures and surface them via - // onUnauthorized. Callers can refresh the token and reconnect. + // connect promise here if we never opened. The browser does not expose + // the HTTP status of a failed upgrade, so reconnect and reserve auth + // recovery for the preceding connection-ticket HTTP request. if (!this.connected) { - this.suppressNextCloseReconnect = true; - settleReject(new WebSocketAuthError()); + settleReject(new Error('WebSocket connection failed')); } }); }); From c2e09a2b5a15c9dd1b0382a468b046278cdb0a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:12:29 +0200 Subject: [PATCH 207/289] fix(kilo-chat): surface badge clear failures --- .../src/__tests__/messages-routes.test.ts | 45 +++++++++++++++++++ .../kilo-chat/src/routes/conversations.ts | 4 +- .../kilo-chat/src/services/conversations.ts | 7 ++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index 79cf45bae1..a630bf9bcf 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -1335,6 +1335,51 @@ describe('recipient conversation read state after message delivery', () => { await expect(recordingNotifications.__listNonZeroBuckets(recipientId)).resolves.toEqual([]); }); + + it('returns retryable failure when required badge clearing fails', async () => { + const { conversationId, recipientId, userApp, recipientApp, deliveredEnv } = + await createMultiHumanConversation('msg-recipient-badge-clear-fails'); + + const createRes = await userApp.request( + '/v1/messages', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, content: sampleContent }), + }, + deliveredEnv + ); + expect(createRes.status).toBe(201); + const { messageId } = await createRes.json<{ messageId: string }>(); + + const failingNotifications = { + ...deliveredEnv.NOTIFICATIONS, + clearBadgeBucketForUser: async () => { + throw new Error('notifications unavailable'); + }, + } as Env['NOTIFICATIONS']; + const failingEnv = { ...deliveredEnv, NOTIFICATIONS: failingNotifications } satisfies Env; + + const markReadRes = await recipientApp.request( + `/v1/conversations/${conversationId}/mark-read`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ lastSeenMessageId: messageId }), + }, + failingEnv + ); + + expect(markReadRes.status).toBe(503); + await expect(markReadRes.json()).resolves.toEqual({ + error: 'Failed to clear notification badge', + }); + + const recipientMemberStub = env.MEMBERSHIP_DO.get(env.MEMBERSHIP_DO.idFromName(recipientId)); + const after = await recipientMemberStub.listConversations({}); + const conversation = after.conversations.find(c => c.conversationId === conversationId); + expect(conversation?.lastReadAt).toBe(ulidToTimestamp(messageId)); + }); }); describe('POST /v1/conversations/:conversationId/messages/:messageId/execute-action', () => { diff --git a/services/kilo-chat/src/routes/conversations.ts b/services/kilo-chat/src/routes/conversations.ts index 7a4d03a505..15afacf7b0 100644 --- a/services/kilo-chat/src/routes/conversations.ts +++ b/services/kilo-chat/src/routes/conversations.ts @@ -197,7 +197,9 @@ export function registerConversationRoutes( makeSchedule(c) ); if (!result.ok) { - return c.json({ error: result.error }, result.code === 'invalid' ? 400 : 403); + if (result.code === 'invalid') return c.json({ error: result.error }, 400); + if (result.code === 'badge_clear_failed') return c.json({ error: result.error }, 503); + return c.json({ error: result.error }, 403); } const response = { diff --git a/services/kilo-chat/src/services/conversations.ts b/services/kilo-chat/src/services/conversations.ts index a9031a31fc..1766036cb7 100644 --- a/services/kilo-chat/src/services/conversations.ts +++ b/services/kilo-chat/src/services/conversations.ts @@ -350,7 +350,7 @@ type BadgeClearResult = { export type MarkReadResult = | { ok: true; applied: boolean; lastReadAt: number; badgeClear: BadgeClearResult | null } - | { ok: false; code: 'forbidden' | 'invalid'; error: string }; + | { ok: false; code: 'forbidden' | 'invalid' | 'badge_clear_failed'; error: string }; export async function markReadFor( env: Env, @@ -399,6 +399,11 @@ export async function markReadFor( conversationId, ...formatError(err), }); + return { + ok: false, + code: 'badge_clear_failed', + error: 'Failed to clear notification badge', + }; } } From e66616038c43771ce1298d470f6224c5ec00e01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:12:40 +0200 Subject: [PATCH 208/289] fix(kilo-chat): return ok for void successes --- .../src/__tests__/bot-messages-routes.test.ts | 40 ++++++++++--------- .../src/__tests__/messages-routes.test.ts | 14 ++++--- .../src/__tests__/reactions-routes.test.ts | 6 ++- .../src/__tests__/sandbox-read-routes.test.ts | 8 ++-- .../src/__tests__/typing-routes.test.ts | 5 ++- .../kilo-chat/src/routes/conversations.ts | 2 +- services/kilo-chat/src/routes/handler.ts | 12 +++--- .../src/services/bot-status-request.ts | 6 +-- 8 files changed, 52 insertions(+), 41 deletions(-) diff --git a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts index 7f46d6c627..6fd87ecc1d 100644 --- a/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/bot-messages-routes.test.ts @@ -191,7 +191,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/messages', () => { { method: 'POST' }, testEnv ); - expect(leaveRes.status).toBe(204); + expect(leaveRes.status).toBe(200); + await expect(leaveRes.json()).resolves.toEqual({ ok: true }); const createAfterLeaveRes = await app.request( `/bot/v1/sandboxes/${sandboxId}/messages`, @@ -238,7 +239,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/messages', () => { // ─── POST .../messages/:messageId/delivery-failed ────────────────────────── describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-failed', () => { - it('flips deliveryFailed and returns 202', async () => { + it('flips deliveryFailed and returns ok', async () => { const pushEvent = vi.fn().mockResolvedValue(false); const { sandboxId, conversationId, messageId, testEnv } = await setupData( 'bot-msg-df-ok', @@ -257,8 +258,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai }, testEnv ); - expect(res.status).toBe(202); - expect(await res.text()).toBe(''); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); expect(pushEvent).toHaveBeenCalledOnce(); expect(pushEvent).toHaveBeenCalledWith( 'user-bot-msg-df-ok', @@ -277,8 +278,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai }, testEnv ); - expect(second.status).toBe(202); - expect(await second.text()).toBe(''); + expect(second.status).toBe(200); + await expect(second.json()).resolves.toEqual({ ok: true }); expect(pushEvent).not.toHaveBeenCalled(); }); @@ -354,7 +355,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../messages/:messageId/delivery-fai { method: 'DELETE' }, testEnv ); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe(200); + await expect(deleteRes.json()).resolves.toEqual({ ok: true }); const res = await app.request( `/bot/v1/sandboxes/${sandboxId}/conversations/${conversationId}/messages/${messageId}/delivery-failed`, @@ -445,7 +447,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed return { sandboxId, conversationId, messageId, testEnv, token }; } - it('reverts resolution and returns 202', async () => { + it('reverts resolution and returns ok', async () => { const { sandboxId, conversationId, messageId, testEnv, token } = await setupWithResolvedAction('bot-act-df-ok'); const app = makeBotApp(); @@ -459,8 +461,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed }, testEnv ); - expect(res.status).toBe(202); - expect(await res.text()).toBe(''); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); }); it('is idempotent when already unresolved', async () => { @@ -481,8 +483,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed }, testEnv ); - expect(first.status).toBe(202); - expect(await first.text()).toBe(''); + expect(first.status).toBe(200); + await expect(first.json()).resolves.toEqual({ ok: true }); expect(pushEvent).toHaveBeenCalledOnce(); const second = await app.request( @@ -494,8 +496,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/.../actions/:groupId/delivery-failed }, testEnv ); - expect(second.status).toBe(202); - expect(await second.text()).toBe(''); + expect(second.status).toBe(200); + await expect(second.json()).resolves.toEqual({ ok: true }); expect(pushEvent).toHaveBeenCalledOnce(); }); @@ -797,7 +799,7 @@ describe('PATCH /bot/v1/sandboxes/:sandboxId/messages/:messageId', () => { // ─── DELETE /bot/v1/sandboxes/:sandboxId/messages/:messageId ───────────────── describe('DELETE /bot/v1/sandboxes/:sandboxId/messages/:messageId', () => { - it('soft-deletes a bot-owned message and returns 204', async () => { + it('soft-deletes a bot-owned message and returns ok', async () => { const { sandboxId, conversationId, testEnv } = await setupData('bot-del-1'); const app = makeBotApp(); const token = await tokenFor(sandboxId); @@ -823,7 +825,8 @@ describe('DELETE /bot/v1/sandboxes/:sandboxId/messages/:messageId', () => { testEnv ); - expect(delRes.status).toBe(204); + expect(delRes.status).toBe(200); + await expect(delRes.json()).resolves.toEqual({ ok: true }); }); it('returns 404 for non-existent message', async () => { @@ -901,7 +904,7 @@ describe('DELETE /bot/v1/sandboxes/:sandboxId/messages/:messageId', () => { // ─── POST /bot/v1/sandboxes/:sandboxId/conversations/:conversationId/typing ─── describe('POST /bot/v1/sandboxes/:sandboxId/conversations/:conversationId/typing', () => { - it('returns 204 for a member bot', async () => { + it('returns ok for a member bot', async () => { const { sandboxId, conversationId, testEnv } = await setupData('bot-typing-ok'); const app = makeBotApp(); const token = await tokenFor(sandboxId); @@ -915,7 +918,8 @@ describe('POST /bot/v1/sandboxes/:sandboxId/conversations/:conversationId/typing testEnv ); - expect(res.status).toBe(204); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); }); it('returns 403 for non-member bot', async () => { diff --git a/services/kilo-chat/src/__tests__/messages-routes.test.ts b/services/kilo-chat/src/__tests__/messages-routes.test.ts index a630bf9bcf..6f4ff870e7 100644 --- a/services/kilo-chat/src/__tests__/messages-routes.test.ts +++ b/services/kilo-chat/src/__tests__/messages-routes.test.ts @@ -502,7 +502,8 @@ describe('PATCH /v1/messages/:id', () => { { method: 'POST' }, env ); - expect(leaveRes.status).toBe(204); + expect(leaveRes.status).toBe(200); + await expect(leaveRes.json()).resolves.toEqual({ ok: true }); const editRes = await userApp.request( `/v1/messages/${messageId}`, @@ -523,7 +524,7 @@ describe('PATCH /v1/messages/:id', () => { }); describe('DELETE /v1/messages/:id', () => { - it('soft-deletes a message and returns 204', async () => { + it('soft-deletes a message and returns ok', async () => { const { conversationId, userApp } = await createConversation('msg-delete-1'); // Create a message @@ -549,7 +550,8 @@ describe('DELETE /v1/messages/:id', () => { env ); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe(200); + await expect(deleteRes.json()).resolves.toEqual({ ok: true }); // Verify message is soft-deleted (appears in list but marked deleted) const convStub = getConvStub(conversationId); @@ -613,7 +615,8 @@ describe('DELETE /v1/messages/:id', () => { { method: 'POST' }, env ); - expect(leaveRes.status).toBe(204); + expect(leaveRes.status).toBe(200); + await expect(leaveRes.json()).resolves.toEqual({ ok: true }); const delQs = new URLSearchParams({ conversationId }); const deleteRes = await userApp.request( @@ -1250,7 +1253,8 @@ describe('recipient conversation read state after message delivery', () => { }, deliveredEnv ); - expect(deleteRes.status).toBe(204); + expect(deleteRes.status).toBe(200); + await expect(deleteRes.json()).resolves.toEqual({ ok: true }); const markReadRes = await recipientApp.request( `/v1/conversations/${conversationId}/mark-read`, diff --git a/services/kilo-chat/src/__tests__/reactions-routes.test.ts b/services/kilo-chat/src/__tests__/reactions-routes.test.ts index a18907d801..938821320e 100644 --- a/services/kilo-chat/src/__tests__/reactions-routes.test.ts +++ b/services/kilo-chat/src/__tests__/reactions-routes.test.ts @@ -214,7 +214,8 @@ describe('POST /v1/messages/:id/reactions', () => { { method: 'DELETE' }, env ); - expect(del.status).toBe(204); + expect(del.status).toBe(200); + await expect(del.json()).resolves.toEqual({ ok: true }); const res = await userApp.request( `/v1/messages/${messageId}/reactions`, @@ -384,7 +385,8 @@ describe('DELETE /v1/messages/:id/reactions', () => { { method: 'DELETE' }, env ); - expect(deleteMessage.status).toBe(204); + expect(deleteMessage.status).toBe(200); + await expect(deleteMessage.json()).resolves.toEqual({ ok: true }); const qs = new URLSearchParams({ conversationId, emoji: '👍' }); const res = await userApp.request( diff --git a/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts b/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts index ef471b1ad2..79d5956227 100644 --- a/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts +++ b/services/kilo-chat/src/__tests__/sandbox-read-routes.test.ts @@ -178,8 +178,8 @@ describe('POST /v1/sandboxes/:sandboxId/request-bot-status', () => { { method: 'POST' }, makeEnv() ); - expect(res.status).toBe(202); - expect(await res.text()).toBe(''); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); const calls = await recordingKiloclaw.__recordedWebhookCalls(); const myCalls = calls.filter(c => c.targetBotId === 'bot:kiloclaw:sandbox-req-fresh'); @@ -207,8 +207,8 @@ describe('POST /v1/sandboxes/:sandboxId/request-bot-status', () => { { method: 'POST' }, testEnv ); - expect(res.status).toBe(202); - expect(await res.text()).toBe(''); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); const calls = await recordingKiloclaw.__recordedWebhookCalls(); const myCalls = calls.filter(c => c.targetBotId === 'bot:kiloclaw:sandbox-req-dedupe'); diff --git a/services/kilo-chat/src/__tests__/typing-routes.test.ts b/services/kilo-chat/src/__tests__/typing-routes.test.ts index 40b9779334..8d6d19775b 100644 --- a/services/kilo-chat/src/__tests__/typing-routes.test.ts +++ b/services/kilo-chat/src/__tests__/typing-routes.test.ts @@ -27,7 +27,7 @@ async function createConversation(userSuffix: string) { } describe('POST /v1/conversations/:id/typing', () => { - it('returns 204 for a member', async () => { + it('returns ok for a member', async () => { const { conversationId, userApp } = await createConversation('typing-member'); const res = await userApp.request( @@ -36,7 +36,8 @@ describe('POST /v1/conversations/:id/typing', () => { env ); - expect(res.status).toBe(204); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true }); }); it('returns 403 for a non-member', async () => { diff --git a/services/kilo-chat/src/routes/conversations.ts b/services/kilo-chat/src/routes/conversations.ts index 15afacf7b0..456dc6d0d3 100644 --- a/services/kilo-chat/src/routes/conversations.ts +++ b/services/kilo-chat/src/routes/conversations.ts @@ -166,7 +166,7 @@ export function registerConversationRoutes( return c.json({ error: result.error }, 403); } - return c.body(null, 204); + return c.json({ ok: true } satisfies OkResponse); }); // POST /v1/conversations/:id/mark-read — mark conversation as read diff --git a/services/kilo-chat/src/routes/handler.ts b/services/kilo-chat/src/routes/handler.ts index 618f0d0647..68cf415cf3 100644 --- a/services/kilo-chat/src/routes/handler.ts +++ b/services/kilo-chat/src/routes/handler.ts @@ -200,7 +200,7 @@ export async function handleDeleteMessage(c: HonoCtx) { if (result.code === 'not_found') return c.json({ error: result.error }, 404); return c.json({ error: result.error }, 500); } - return new Response(null, { status: 204 }); + return c.json({ ok: true } satisfies OkResponse); } // ─── executeAction ────────────────────────────────────────────────────────── @@ -273,7 +273,7 @@ export async function handleMessageDeliveryFailed(c: HonoCtx) { if (!result.ok) { return c.json({ error: result.error }, 404); } - return c.body(null, 202); + return c.json({ ok: true } satisfies OkResponse); } // ─── actionDeliveryFailed (bot-reported) ──────────────────────────────────── @@ -309,7 +309,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { return c.json({ error: result.error }, 404); } if (!result.reverted) { - return c.body(null, 202); + return c.json({ ok: true } satisfies OkResponse); } const ctx = await getConversationContext(c.env, convId.data); @@ -323,7 +323,7 @@ export async function handleActionDeliveryFailed(c: HonoCtx) { { conversationId: convId.data, messageId, groupId: groupId.data } ); } - return c.body(null, 202); + return c.json({ ok: true } satisfies OkResponse); } // ─── addReaction ───────────────────────────────────────────────────────────── @@ -467,7 +467,7 @@ export async function handleSetTyping(c: HonoCtx) { if (!result.ok) { return c.json({ error: result.error }, 403); } - return new Response(null, { status: 204 }); + return c.json({ ok: true } satisfies OkResponse); } export async function handleStopTyping(c: HonoCtx) { @@ -479,7 +479,7 @@ export async function handleStopTyping(c: HonoCtx) { if (!result.ok) { return c.json({ error: result.error }, 403); } - return new Response(null, { status: 204 }); + return c.json({ ok: true } satisfies OkResponse); } // ─── listBotConversations ──────────────────────────────────────────────────── diff --git a/services/kilo-chat/src/services/bot-status-request.ts b/services/kilo-chat/src/services/bot-status-request.ts index e60ebf9890..69596ea0a5 100644 --- a/services/kilo-chat/src/services/bot-status-request.ts +++ b/services/kilo-chat/src/services/bot-status-request.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import type { z } from 'zod'; import type { AuthContext } from '../auth'; -import { sandboxIdSchema, type chatWebhookRpcSchema } from '@kilocode/kilo-chat'; +import { sandboxIdSchema, type OkResponse, type chatWebhookRpcSchema } from '@kilocode/kilo-chat'; import { formatError, withDORetry } from '@kilocode/worker-utils'; import { logger } from '../util/logger'; import { userOwnsSandbox } from './sandbox-ownership'; @@ -46,11 +46,11 @@ export async function handleRequestBotStatus(c: HonoCtx): Promise { // The fan-out already pushed the event to all of this user's connections; // skipping here keeps webhook QPS at ~1 per 15s per sandbox regardless of // how many clients are subscribed. - return c.body(null, 202); + return c.json({ ok: true } satisfies OkResponse); } c.executionCtx.waitUntil(triggerBotStatusWebhook(c.env, sandboxId)); - return c.body(null, 202); + return c.json({ ok: true } satisfies OkResponse); } /** From 33dbb6135af7ac911016098c169b01a96d4a2e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:13:43 +0200 Subject: [PATCH 209/289] fix(kilo-chat-hooks): refetch incomplete activity reorders --- .../src/use-conversations.test.ts | 61 ++++++++++++++++++- .../kilo-chat-hooks/src/use-conversations.ts | 40 +++++++++--- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/kilo-chat-hooks/src/use-conversations.test.ts b/packages/kilo-chat-hooks/src/use-conversations.test.ts index ca092f6748..30da5dcd6f 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.test.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.test.ts @@ -95,7 +95,7 @@ describe('applyConversationActivityToPages', () => { conversation('conversation-d', { lastActivityAt: 150, joinedAt: 150 }), ], ], - ['cursor-1', null] + [null, null] ); const result = applyConversationActivityToPages(data, { @@ -111,7 +111,7 @@ describe('applyConversationActivityToPages', () => { 'conversation-c', ]); expect(result.data?.pages.map(page => page.conversations.length)).toEqual([2, 2]); - expect(result.data?.pages.map(page => page.nextCursor)).toEqual(['cursor-1', null]); + expect(result.data?.pages.map(page => page.nextCursor)).toEqual([null, null]); }); it('moves a page-2 row only within later loaded rows when page 1 still sorts ahead', () => { @@ -127,7 +127,7 @@ describe('applyConversationActivityToPages', () => { conversation('conversation-e', { lastActivityAt: 100, joinedAt: 100 }), ], ], - ['cursor-1', null] + [null, null] ); const result = applyConversationActivityToPages(data, { @@ -146,6 +146,61 @@ describe('applyConversationActivityToPages', () => { expect(result.data?.pages.map(page => page.conversations.length)).toEqual([2, 3]); }); + it('falls back to invalidation when incomplete loaded pages would need repartitioning', () => { + const data = conversationsData( + [ + [ + conversation('conversation-a', { lastActivityAt: 300, joinedAt: 300 }), + conversation('conversation-b', { lastActivityAt: 250, joinedAt: 250 }), + ], + [ + conversation('conversation-c', { lastActivityAt: 200, joinedAt: 200 }), + conversation('conversation-d', { lastActivityAt: 150, joinedAt: 150 }), + ], + ], + ['cursor-1', null] + ); + + const result = applyConversationActivityToPages(data, { + conversationId: 'conversation-d', + lastActivityAt: 400, + }); + + expect(result.applied).toBe(false); + expect(result.data).toBe(data); + }); + + it('updates incomplete loaded pages in place when activity does not change ordering', () => { + const data = conversationsData( + [ + [ + conversation('conversation-a', { lastActivityAt: 500, joinedAt: 500 }), + conversation('conversation-b', { lastActivityAt: 450, joinedAt: 450 }), + ], + [ + conversation('conversation-c', { lastActivityAt: 300, joinedAt: 300 }), + conversation('conversation-d', { lastActivityAt: 200, joinedAt: 200 }), + ], + ], + ['cursor-1', null] + ); + + const result = applyConversationActivityToPages(data, { + conversationId: 'conversation-d', + lastActivityAt: 250, + }); + + expect(result.applied).toBe(true); + expectCompleteLoadedOrder(result.data, [ + 'conversation-a', + 'conversation-b', + 'conversation-c', + 'conversation-d', + ]); + expect(result.data?.pages[1]?.conversations[1]?.lastActivityAt).toBe(250); + expect(result.data?.pages.map(page => page.nextCursor)).toEqual(['cursor-1', null]); + }); + it('treats stale page-2 activity as applied without changing loaded rows', () => { const data = conversationsData( [ diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 3ed43e1716..0264c5ecea 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -211,14 +211,38 @@ export function applyConversationActivityToPages( return { data, applied: true }; } - const sortedConversations = data.pages - .flatMap(page => page.conversations) - .map(c => - c.conversationId === activity.conversationId - ? { ...c, lastActivityAt: activity.lastActivityAt } - : c - ) - .sort(compareConversationsByActivity); + const loadedConversations = data.pages.flatMap(page => page.conversations); + const updatedConversations = loadedConversations.map(c => + c.conversationId === activity.conversationId + ? { ...c, lastActivityAt: activity.lastActivityAt } + : c + ); + const sortedConversations = [...updatedConversations].sort(compareConversationsByActivity); + + if (data.pages.some(page => page.hasMore)) { + const orderChanged = sortedConversations.some( + (conversation, index) => + conversation.conversationId !== loadedConversations[index]?.conversationId + ); + if (orderChanged) { + return { data, applied: false }; + } + + return { + data: { + ...data, + pages: data.pages.map(page => ({ + ...page, + conversations: page.conversations.map(c => + c.conversationId === activity.conversationId + ? { ...c, lastActivityAt: activity.lastActivityAt } + : c + ), + })), + }, + applied: true, + }; + } let nextConversationOffset = 0; From f210b1649188af66b4133b3fe5e8e1ba0017dd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:14:17 +0200 Subject: [PATCH 210/289] fix(notifications): omit tokens from ticket errors --- services/notifications/src/__tests__/dispatch-push.test.ts | 2 -- services/notifications/src/lib/expo-push.test.ts | 1 - services/notifications/src/lib/expo-push.ts | 2 -- services/notifications/src/lib/notifications-service.test.ts | 2 -- 4 files changed, 7 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index 7c9f6c8e20..a4114a32c0 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -257,7 +257,6 @@ describe('NotificationChannelDO.dispatchPush', () => { staleTokens: [], ticketErrors: [ { - token: 'tok1', errorCode: 'MessageTooBig', message: 'Message is too big', retryable: false, @@ -364,7 +363,6 @@ describe('NotificationChannelDO.dispatchPush', () => { staleTokens: [], ticketErrors: [ { - token: 'tok-rate-limited', errorCode: 'MessageRateExceeded', message: 'Rate limited', retryable: true, diff --git a/services/notifications/src/lib/expo-push.test.ts b/services/notifications/src/lib/expo-push.test.ts index 96e5142d03..cbff52804f 100644 --- a/services/notifications/src/lib/expo-push.test.ts +++ b/services/notifications/src/lib/expo-push.test.ts @@ -91,7 +91,6 @@ describe('sendPushNotifications', () => { staleTokens: [], ticketErrors: [ { - token: 'ExponentPushToken[token-1]', errorCode: 'MessageTooBig', message: 'Message is too big', retryable: false, diff --git a/services/notifications/src/lib/expo-push.ts b/services/notifications/src/lib/expo-push.ts index 2d581d330e..9e2310560b 100644 --- a/services/notifications/src/lib/expo-push.ts +++ b/services/notifications/src/lib/expo-push.ts @@ -9,7 +9,6 @@ export type TicketTokenPair = { }; export type PushTicketError = { - token: string; errorCode: string | undefined; message: string; retryable: boolean; @@ -102,7 +101,6 @@ export async function sendPushNotifications( } else { const errorCode = ticket.details?.error; ticketErrors.push({ - token, errorCode, message: ticket.message, retryable: isRetryableTicketError(errorCode), diff --git a/services/notifications/src/lib/notifications-service.test.ts b/services/notifications/src/lib/notifications-service.test.ts index 50ccbc91ef..59c6a2b076 100644 --- a/services/notifications/src/lib/notifications-service.test.ts +++ b/services/notifications/src/lib/notifications-service.test.ts @@ -207,13 +207,11 @@ describe('dispatchInstanceLifecyclePush', () => { staleTokens: [], ticketErrors: [ { - token: 'ExponentPushToken[aaa]', errorCode: 'MessageTooBig', message: 'Message is too big', retryable: false, }, { - token: 'ExponentPushToken[bbb]', errorCode: 'MessageRateExceeded', message: 'Rate exceeded', retryable: true, From 98586b9c49e88993570b9c4d324bd1a8eef24f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:14:43 +0200 Subject: [PATCH 211/289] fix(web): gate org chat layout server-side --- .../claw/chat/OrgChatRootLayoutClient.tsx | 35 +++++++++++++++ .../organizations/[id]/claw/chat/layout.tsx | 43 +++++++------------ 2 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/chat/OrgChatRootLayoutClient.tsx diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/chat/OrgChatRootLayoutClient.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/chat/OrgChatRootLayoutClient.tsx new file mode 100644 index 0000000000..253f0519fb --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/claw/chat/OrgChatRootLayoutClient.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useUser } from '@/hooks/useUser'; +import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; +import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; + +export function OrgChatRootLayoutClient({ + children, + organizationId, +}: { + children: React.ReactNode; + organizationId: string; +}) { + const { data: user } = useUser(); + const { data: status, error, isError, isLoading, refetch } = useOrgKiloClawStatus(organizationId); + const instanceErrorMessage = + error instanceof Error ? error.message : error ? 'Unknown error' : null; + + return ( + void refetch()} + assistantName={status?.botName ?? null} + > + {children} + + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx index c8edac14d7..b3dc213091 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/chat/layout.tsx @@ -1,32 +1,21 @@ -'use client'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { OrgChatRootLayoutClient } from './OrgChatRootLayoutClient'; -import { useParams } from 'next/navigation'; -import { useUser } from '@/hooks/useUser'; -import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; -import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; - -export default function OrgChatRootLayout({ children }: { children: React.ReactNode }) { - const params = useParams<{ id: string }>(); - const organizationId = params.id; - const { data: user } = useUser(); - const { data: status, error, isError, isLoading, refetch } = useOrgKiloClawStatus(organizationId); - const instanceErrorMessage = - error instanceof Error ? error.message : error ? 'Unknown error' : null; +type OrgChatRootLayoutProps = { + children: React.ReactNode; + params: Promise<{ id: string }>; +}; +export default async function OrgChatRootLayout({ children, params }: OrgChatRootLayoutProps) { return ( - void refetch()} - assistantName={status?.botName ?? null} - > - {children} - + ( + + {children} + + )} + /> ); } From 8c9e8c8453bae2e63dd6129d521983412a092121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:14:57 +0200 Subject: [PATCH 212/289] fix(web): apply billing wrapper to personal chat --- apps/web/src/app/(app)/claw/chat/layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/claw/chat/layout.tsx b/apps/web/src/app/(app)/claw/chat/layout.tsx index 0dd73d0d13..fbe24ee477 100644 --- a/apps/web/src/app/(app)/claw/chat/layout.tsx +++ b/apps/web/src/app/(app)/claw/chat/layout.tsx @@ -3,6 +3,7 @@ import { useUser } from '@/hooks/useUser'; import { useKiloClawStatus } from '@/hooks/useKiloClaw'; import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; +import { BillingWrapper } from '@/app/(app)/claw/components/billing/BillingWrapper'; export default function ChatRootLayout({ children }: { children: React.ReactNode }) { const { data: user } = useUser(); @@ -10,7 +11,7 @@ export default function ChatRootLayout({ children }: { children: React.ReactNode const instanceErrorMessage = error instanceof Error ? error.message : error ? 'Unknown error' : null; - return ( + const content = ( ); + + return {content}; } From 08d3cdfe6140b63a9d9791edb31e87b4a0669041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 03:15:15 +0200 Subject: [PATCH 213/289] fix(env-sync): resolve suffixed local secret sources --- dev/local/env-sync/plan.test.ts | 42 +++++++++++++++++++++++++++++++++ dev/local/env-sync/plan.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index df85537ad1..40ddcd582c 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -231,6 +231,48 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var } }); +test('auto-creates Secrets Store binding from exact suffixed local dev vars before base fallback', () => { + const repo = createRepo({ + '.env.local': '', + 'services/kiloclaw/.dev.vars.example': [ + 'GATEWAY_TOKEN_SECRET=dev-gateway-secret-kiloclaw', + 'GATEWAY_TOKEN_SECRET_DEV=dev-gateway-secret-kiloclaw-dev', + '', + ].join('\n'), + 'services/kilo-chat/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/kilo-chat/wrangler.jsonc': `{ + "secrets_store_secrets": [ + { + "binding": "GATEWAY_TOKEN_SECRET", + "store_id": "store-id", + "secret_name": "GATEWAY_TOKEN_SECRET_DEV" + } + ] + }`, + }); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['kilo-chat'])); + assert.equal(plan.missingEnvLocal, false); + assert.deepEqual(plan.secretStoreWarnings, []); + assert.deepEqual(plan.secretStoreAutoCreates, [ + { + workerDir: 'services/kilo-chat', + binding: { + binding: 'GATEWAY_TOKEN_SECRET', + store_id: 'store-id', + secret_name: 'GATEWAY_TOKEN_SECRET_DEV', + }, + sourceKey: 'services/kiloclaw/.dev.vars.example:GATEWAY_TOKEN_SECRET_DEV', + value: 'dev-gateway-secret-kiloclaw-dev', + }, + ]); + }); + } finally { + repo.cleanup(); + } +}); + test('keeps .env.local values ahead of wrangler vars for local overrides', () => { const repo = createCloudAgentNextRepo({ envLocal: 'R2_ATTACHMENTS_BUCKET=local-attachments\n', diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index 59828cbf4f..1ba7af8982 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -265,7 +265,7 @@ function resolveSecretStoreSource( return { sourceKey: baseKey, value: envLocalValue }; } - return localSecretSources.get(baseKey); + return localSecretSources.get(secretName) ?? localSecretSources.get(baseKey); } function collectLocalSecretSources( From 0212c49abafd548d4307248f74386d0af6e879e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:27:22 +0200 Subject: [PATCH 214/289] fix(web): show chat creation progress --- .../kilo-chat/components/ConversationList.tsx | 70 +++++++++++++++++-- .../kilo-chat/components/KiloChatLayout.tsx | 25 ++++++- .../conversation-list-state.test.ts | 33 +++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/kilo-chat/components/conversation-list-state.test.ts diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx index b9172c910f..8a02769951 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/ConversationList.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useMemo } from 'react'; -import { Plus } from 'lucide-react'; +import { AlertTriangle, Loader2, Plus } from 'lucide-react'; import { useParams } from 'next/navigation'; import type { ConversationListItem } from '@kilocode/kilo-chat'; import { ConversationItem } from './ConversationItem'; @@ -48,17 +48,58 @@ type ConversationListProps = { isLoading: boolean; hasNextPage?: boolean; isFetchingNextPage?: boolean; + isCreatingConversation?: boolean; + newConversationError?: string | null; onLoadMore?: () => void; onNewConversation: () => void; onRename: (id: string, title: string) => void; onLeave: (id: string) => void; }; +type NewConversationUiState = { + buttonLabel: string; + buttonTitle: string; + disabled: boolean; + emptyText: string; + showError: boolean; +}; + +export function buildNewConversationUiState({ + isCreatingConversation, + newConversationError, +}: { + isCreatingConversation: boolean; + newConversationError: string | null; +}): NewConversationUiState { + if (isCreatingConversation) { + return { + buttonLabel: 'Creating conversation', + buttonTitle: 'Creating conversation', + disabled: true, + emptyText: 'Creating conversation...', + showError: false, + }; + } + + return { + buttonLabel: 'New conversation', + buttonTitle: 'New conversation', + disabled: false, + emptyText: + newConversationError === null + ? 'No conversations yet' + : 'No conversations yet. Create one to start chatting.', + showError: newConversationError !== null, + }; +} + export function ConversationList({ conversations, isLoading, hasNextPage, isFetchingNextPage, + isCreatingConversation = false, + newConversationError = null, onLoadMore, onNewConversation, onRename, @@ -67,6 +108,10 @@ export function ConversationList({ const params = useParams<{ conversationId?: string }>(); const activeId = params?.conversationId; const groups = useMemo(() => groupConversations(conversations), [conversations]); + const newConversationUi = buildNewConversationUiState({ + isCreatingConversation, + newConversationError, + }); const handleScroll = useCallback( (e: React.UIEvent) => { @@ -85,21 +130,34 @@ export function ConversationList({ Conversations
+ {newConversationUi.showError && ( +
+ + {newConversationError} +
+ )} +
{isLoading ? (
Loading...
) : conversations.length === 0 ? (
- No conversations yet + {newConversationUi.emptyText}
) : ( <> diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 9a5e836a05..5cb7ff2346 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -80,6 +80,7 @@ export function KiloChatLayout({ const createConversation = useCreateConversation(kiloChatClient); const renameConversation = useRenameConversation(kiloChatClient); const leaveConversation = useLeaveConversation(kiloChatClient); + const [newConversationError, setNewConversationError] = useState(null); const handleRename = useCallback( (conversationId: string, title: string) => { @@ -114,17 +115,33 @@ export function KiloChatLayout({ ); const handleNewConversation = useCallback(() => { - if (!sandboxId) return; + if (!sandboxId || createConversation.isPending) return; + setNewConversationError(null); createConversation.mutate( { sandboxId }, { onSuccess: res => { + setNewConversationError(null); router.push(`${basePath}/${res.conversationId}`); }, - onError: err => toast.error(formatKiloChatError(err, 'Failed to create conversation')), + onError: err => { + const message = formatKiloChatError( + err, + "Couldn't create conversation. Check your connection and try again." + ); + setNewConversationError(message); + toast.error(message); + }, } ); - }, [sandboxId, basePath, createConversation.mutate, router]); + }, [ + sandboxId, + basePath, + createConversation.isPending, + createConversation.mutate, + router, + setNewConversationError, + ]); const contextValue = useMemo( () => ({ @@ -169,6 +186,8 @@ export function KiloChatLayout({ isLoading={isLoading} hasNextPage={!!hasNextPage} isFetchingNextPage={isFetchingNextPage} + isCreatingConversation={createConversation.isPending} + newConversationError={newConversationError} onLoadMore={() => void fetchNextPage()} onNewConversation={handleNewConversation} onRename={handleRename} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-list-state.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-list-state.test.ts new file mode 100644 index 0000000000..e8bd3eb6db --- /dev/null +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/conversation-list-state.test.ts @@ -0,0 +1,33 @@ +import { buildNewConversationUiState } from './ConversationList'; + +describe('buildNewConversationUiState', () => { + it('disables creation while a conversation is pending', () => { + expect( + buildNewConversationUiState({ + isCreatingConversation: true, + newConversationError: null, + }) + ).toEqual({ + buttonLabel: 'Creating conversation', + buttonTitle: 'Creating conversation', + disabled: true, + emptyText: 'Creating conversation...', + showError: false, + }); + }); + + it('keeps durable inline error state after a failed creation', () => { + expect( + buildNewConversationUiState({ + isCreatingConversation: false, + newConversationError: "Couldn't create conversation. Check your connection and try again.", + }) + ).toEqual({ + buttonLabel: 'New conversation', + buttonTitle: 'New conversation', + disabled: false, + emptyText: 'No conversations yet. Create one to start chatting.', + showError: true, + }); + }); +}); From bb7ccc70aebea18626fddee19473cb84dbd67eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:33:39 +0200 Subject: [PATCH 215/289] fix(mobile): recover kilo chat user id after token retry --- .../hooks/use-kilo-chat-token.test.ts | 64 +++++++++++++++++++ .../kilo-chat/hooks/use-kilo-chat-token.ts | 13 ++++ .../kilo-chat/kilo-chat-provider.tsx | 12 +++- 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts new file mode 100644 index 0000000000..4ea136b72c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getItemAsync: vi.fn<() => Promise>(), + getTokenQuery: vi.fn<() => Promise<{ token: string; userId: string; expiresAt: string }>>(), +})); + +vi.mock('react', () => ({ + useCallback: unknown>(fn: T) => fn, +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: mocks.getItemAsync, +})); + +vi.mock('@/lib/storage-keys', () => ({ + AUTH_TOKEN_KEY: 'auth-token', +})); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + kiloChat: { + getToken: { + query: mocks.getTokenQuery, + }, + }, + }, +})); + +describe('useKiloChatTokenResponseGetter', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { clearKiloChatTokenCache } = await import('./use-kilo-chat-token'); + clearKiloChatTokenCache(); + }); + + it('notifies subscribers after a later token fetch succeeds', async () => { + const response = { + token: 'kilo-jwt', + userId: 'user-1', + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }; + const seenUserIds: string[] = []; + + mocks.getItemAsync.mockResolvedValue('auth-token-1'); + mocks.getTokenQuery.mockRejectedValueOnce(new Error('network down')); + mocks.getTokenQuery.mockResolvedValueOnce(response); + + const { subscribeToKiloChatTokenResponses, useKiloChatTokenResponseGetter } = + await import('./use-kilo-chat-token'); + const unsubscribe = subscribeToKiloChatTokenResponses(tokenResponse => { + seenUserIds.push(tokenResponse.userId); + }); + const getTokenResponse = useKiloChatTokenResponseGetter(); + + await expect(getTokenResponse()).rejects.toThrow('network down'); + expect(seenUserIds).toEqual([]); + + await expect(getTokenResponse()).resolves.toBe(response); + expect(seenUserIds).toEqual(['user-1']); + + unsubscribe(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts index f95b384987..c784652d8e 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -12,17 +12,27 @@ type TokenCache = { expiresAtMs: number; }; +type TokenResponseListener = (response: KiloChatTokenResponse) => void; + // Module-level cache keyed on the user's auth token, so a sign-out followed by // a different sign-in within the JWT window doesn't return the previous user's // token. The in-flight ref is keyed the same way for the same reason. let cache: TokenCache | null = null; let inFlight: { authToken: string; promise: Promise } | null = null; +const tokenResponseListeners = new Set(); export function clearKiloChatTokenCache(): void { cache = null; inFlight = null; } +export function subscribeToKiloChatTokenResponses(listener: TokenResponseListener): () => void { + tokenResponseListeners.add(listener); + return () => { + tokenResponseListeners.delete(listener); + }; +} + /** * Returns a stable getter function that fetches a kilo-chat JWT, caching it * until 60 seconds before expiry. Concurrent callers share a single in-flight @@ -72,5 +82,8 @@ export function useKiloChatTokenResponseGetter(): () => Promise { const response = await trpcClient.kiloChat.getToken.query(); cache = { authToken, response, expiresAtMs: new Date(response.expiresAt).getTime() }; + for (const listener of tokenResponseListeners) { + listener(response); + } return response; } diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index 409e7c6f51..147fee52bd 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -8,6 +8,7 @@ import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config'; import { clearKiloChatTokenCache, + subscribeToKiloChatTokenResponses, useKiloChatTokenGetter, useKiloChatTokenResponseGetter, } from './hooks/use-kilo-chat-token'; @@ -49,6 +50,11 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { useEffect(() => { let cancelled = false; + const unsubscribe = subscribeToKiloChatTokenResponses(response => { + if (!cancelled) { + setCurrentUserId(response.userId); + } + }); async function resolveCurrentUserId() { try { @@ -57,9 +63,8 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { setCurrentUserId(response.userId); } } catch { - if (!cancelled) { - setCurrentUserId(null); - } + // Keep the provider in its loading state. A later successful token fetch + // from any Kilo Chat caller will notify the subscription above. } } @@ -67,6 +72,7 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { return () => { cancelled = true; + unsubscribe(); }; }, [getTokenResponse]); From b361e13f506e881aa632d366ecc6d00f247b41de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:34:48 +0200 Subject: [PATCH 216/289] fix(mobile): separate chat list loading states --- .../conversation-list-screen.test.ts | 21 +++++++ .../kilo-chat/conversation-list-screen.tsx | 55 ++++++++++++++++++- .../kilo-chat/conversation-list-state.ts | 22 ++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts b/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts new file mode 100644 index 0000000000..cfe93a77f3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { getConversationListContentState } from './conversation-list-state'; + +describe('getConversationListContentState', () => { + it('keeps the empty conversation CTA out of pending and error states', () => { + expect( + getConversationListContentState({ isPending: true, isError: false, hasData: false }) + ).toBe('loading'); + + expect( + getConversationListContentState({ isPending: false, isError: true, hasData: false }) + ).toBe('error'); + }); + + it('allows the empty conversation CTA only after a successful empty response', () => { + expect( + getConversationListContentState({ isPending: false, isError: false, hasData: true }) + ).toBe('ready'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index 02ebd40f4e..f9002bae93 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -4,12 +4,14 @@ import { useCallback } from 'react'; import { Pressable, View } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { timeAgo } from '@/lib/utils'; import { EmptyConversationList } from './empty-conversation-list'; +import { getConversationListContentState } from './conversation-list-state'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; import { useConversations, useCreateConversation } from './hooks/use-conversations'; import { useInstancePresence } from './hooks/use-instance-presence'; @@ -32,6 +34,22 @@ type ConversationRowProps = { onPress: (id: string) => void; }; +function ConversationListSkeleton() { + return ( + + {[0, 1, 2, 3].map(i => ( + + + + + + + + ))} + + ); +} + function ConversationRow({ item, onPress }: ConversationRowProps) { const hasUnread = item.lastActivityAt !== null && @@ -70,7 +88,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const listQuery = useConversations(client, sandboxId); const createConversation = useCreateConversation(client); - const conversations = listQuery.data?.conversations ?? []; const hasNextPage = listQuery.hasNextPage; const isFetchingNextPage = listQuery.isFetchingNextPage; const fetchNextPage = listQuery.fetchNextPage; @@ -98,6 +115,42 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { } }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + const contentState = getConversationListContentState({ + isPending: listQuery.isPending, + isError: listQuery.isError, + hasData: listQuery.data !== undefined, + }); + + if (contentState === 'loading') { + return ( + + + + + + + ); + } + + if (contentState === 'error') { + return ( + + + + { + void listQuery.refetch(); + }} + /> + + + ); + } + + const conversations = listQuery.data?.conversations ?? []; + return ( diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-state.ts b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts new file mode 100644 index 0000000000..998a2a639c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts @@ -0,0 +1,22 @@ +export type ConversationListContentState = 'loading' | 'error' | 'ready'; + +export function getConversationListContentState({ + isPending, + isError, + hasData, +}: { + isPending: boolean; + isError: boolean; + hasData: boolean; +}): ConversationListContentState { + if (isPending) { + return 'loading'; + } + if (isError) { + return 'error'; + } + if (!hasData) { + return 'loading'; + } + return 'ready'; +} From 6b48adc069afc35490a44968cb8df7588bfa4761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:35:42 +0200 Subject: [PATCH 217/289] fix(mobile): gate chat history initial load --- .../kilo-chat/conversation-screen.tsx | 66 ++++++++++++++++++- .../kilo-chat/message-history-state.test.ts | 21 ++++++ .../kilo-chat/message-history-state.ts | 22 +++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-history-state.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-history-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 94a71bc32c..e7495fcc99 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -23,6 +23,8 @@ import { useFocusEffect } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; +import { QueryError } from '@/components/query-error'; +import { Skeleton } from '@/components/ui/skeleton'; import { ConversationHeader } from './conversation-header'; import { resolveMobileMessageInputAvailability } from './bot-send-state'; import { executeActionWithMobileFeedback } from './execute-action-feedback'; @@ -35,6 +37,7 @@ import { canToggleReaction, createSendMessageClientId, } from './message-presentation'; +import { getMessageHistoryContentState } from './message-history-state'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; import { useMobileTypingState, useTypingSender } from './hooks/use-typing'; @@ -58,6 +61,16 @@ function editableText(message: Message): string { .join('\n'); } +function MessageHistorySkeleton() { + return ( + + + + + + ); +} + export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { const client = useKiloChatClient(); const currentUserId = useCurrentUserId(); @@ -78,7 +91,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const now = useNowTicker(10_000); const messagesQuery = useMessages(client, conversationId); - const messages = messagesQuery.data?.messages ?? []; + const messageHistoryState = getMessageHistoryContentState({ + isPending: messagesQuery.isPending, + isError: messagesQuery.isError, + hasData: messagesQuery.data !== undefined, + }); + const hasInitialMessages = messageHistoryState === 'ready'; + const messages = hasInitialMessages ? (messagesQuery.data?.messages ?? []) : []; const latestMessageId = latestMarkReadMessageId(messages); const fetchOlder = useCallback(() => { if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { @@ -306,7 +325,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl activeAndFocusedRef.current = activeAndFocused; const markCurrentConversationRead = useCallback(() => { - if (latestMessageId === null || currentMarkReadMarker === null) { + if (!hasInitialMessages || latestMessageId === null || currentMarkReadMarker === null) { return; } const marker = currentMarkReadMarker; @@ -323,7 +342,14 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl markCurrentConversationReadRef.current?.(); }, }); - }, [conversationId, currentMarkReadMarker, latestMessageId, markRead, sandboxId]); + }, [ + conversationId, + currentMarkReadMarker, + hasInitialMessages, + latestMessageId, + markRead, + sandboxId, + ]); markCurrentConversationReadRef.current = markCurrentConversationRead; useEffect(() => { @@ -362,6 +388,40 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [sandboxId, conversationId]) ); + if (messageHistoryState === 'loading') { + return ( + + + + + + + ); + } + + if (messageHistoryState === 'error') { + return ( + + + + { + void messagesQuery.refetch(); + }} + /> + + + ); + } + return ( diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.test.ts b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts new file mode 100644 index 0000000000..67922443a7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-history-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { getMessageHistoryContentState } from './message-history-state'; + +describe('getMessageHistoryContentState', () => { + it('blocks the composer while the initial history is pending or errored', () => { + expect(getMessageHistoryContentState({ isPending: true, isError: false, hasData: false })).toBe( + 'loading' + ); + + expect(getMessageHistoryContentState({ isPending: false, isError: true, hasData: false })).toBe( + 'error' + ); + }); + + it('allows the chat surface after the initial history loads', () => { + expect(getMessageHistoryContentState({ isPending: false, isError: false, hasData: true })).toBe( + 'ready' + ); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.ts b/apps/mobile/src/components/kilo-chat/message-history-state.ts new file mode 100644 index 0000000000..d8c49a6cbe --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-history-state.ts @@ -0,0 +1,22 @@ +export type MessageHistoryContentState = 'loading' | 'error' | 'ready'; + +export function getMessageHistoryContentState({ + isPending, + isError, + hasData, +}: { + isPending: boolean; + isError: boolean; + hasData: boolean; +}): MessageHistoryContentState { + if (isPending) { + return 'loading'; + } + if (isError) { + return 'error'; + } + if (!hasData) { + return 'loading'; + } + return 'ready'; +} From 272407bcfa148d5b977b0844ae9fe494cd4bfc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:36:57 +0200 Subject: [PATCH 218/289] fix(mobile): guard chat route sandbox mismatches --- .../chat/[sandbox-id]/[conversation-id].tsx | 25 ++++++++--- .../conversation-route-state.test.ts | 41 +++++++++++++++-- .../kilo-chat/conversation-route-state.ts | 44 ++++++++++++++----- .../conversation-route-guard.ts | 17 +------ packages/kilo-chat/src/index.ts | 1 + packages/kilo-chat/src/route-helpers.ts | 16 +++++++ 6 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 packages/kilo-chat/src/route-helpers.ts diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx index 8410014457..8928ddc8f4 100644 --- a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner-native'; import { ConversationScreen } from '@/components/kilo-chat/conversation-screen'; import { + getConversationRouteDecision, getConversationRouteErrorMessage, shouldRenderConversationScreen, } from '@/components/kilo-chat/conversation-route-state'; @@ -18,16 +19,30 @@ export default function ChatConversationRoute() { const client = useKiloChatClient(); const conversationDetail = useConversationDetail(client, conversationId); const redirectPath = `/(app)/chat/${sandboxId}` as Href; + const routeDecision = getConversationRouteDecision({ + detail: conversationDetail, + routeSandboxId: sandboxId, + }); useEffect(() => { - if (!conversationDetail.isError) { + if (conversationDetail.isError) { + toast.error(getConversationRouteErrorMessage(conversationDetail.error)); + router.replace(redirectPath); return; } - toast.error(getConversationRouteErrorMessage(conversationDetail.error)); - router.replace(redirectPath); - }, [conversationDetail.isError, conversationDetail.error, redirectPath, router]); + if (routeDecision === 'not-found') { + toast.error('Conversation not found'); + router.replace(redirectPath); + } + }, [conversationDetail.error, conversationDetail.isError, redirectPath, routeDecision, router]); - if (!shouldRenderConversationScreen(conversationDetail)) { + if ( + !shouldRenderConversationScreen({ + detail: conversationDetail, + routeSandboxId: sandboxId, + }) || + !conversationDetail.data + ) { return null; } diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts index 82df64bd24..583911090a 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.test.ts @@ -2,6 +2,7 @@ import { KiloChatApiError } from '@kilocode/kilo-chat'; import { describe, expect, it } from 'vitest'; import { + getConversationRouteDecision, getConversationRouteErrorMessage, shouldRenderConversationScreen, } from './conversation-route-state'; @@ -22,15 +23,49 @@ describe('getConversationRouteErrorMessage', () => { describe('shouldRenderConversationScreen', () => { it('does not render while the conversation detail is loading', () => { - expect(shouldRenderConversationScreen({ data: undefined, isError: false })).toBe(false); + expect( + shouldRenderConversationScreen({ + detail: { data: undefined, isError: false }, + routeSandboxId: 'sandbox-1', + }) + ).toBe(false); }); it('renders after conversation detail loads successfully', () => { expect( shouldRenderConversationScreen({ - data: { title: 'Kilo Chat' }, - isError: false, + detail: { + data: { + title: 'Kilo Chat', + members: [ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot' }, + ], + }, + isError: false, + }, + routeSandboxId: 'sandbox-1', }) ).toBe(true); }); }); + +describe('getConversationRouteDecision', () => { + it('rejects conversations that belong to a different sandbox route', () => { + expect( + getConversationRouteDecision({ + detail: { + data: { + title: 'Kilo Chat', + members: [ + { id: 'user-1', kind: 'user' }, + { id: 'bot:kiloclaw:sandbox-b', kind: 'bot' }, + ], + }, + isError: false, + }, + routeSandboxId: 'sandbox-a', + }) + ).toBe('not-found'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts index 40f781f760..232d388773 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts @@ -1,14 +1,15 @@ -import { KiloChatApiError } from '@kilocode/kilo-chat'; +import { + type ConversationMember, + conversationSandboxIdFromMembers, + KiloChatApiError, +} from '@kilocode/kilo-chat'; type ConversationRouteDetailState = { - data: { title: string | null } | null | undefined; + data: { title: string | null; members: ConversationMember[] } | null | undefined; isError: boolean; }; -type RenderableConversationRouteDetailState = ConversationRouteDetailState & { - data: { title: string | null }; - isError: false; -}; +export type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found'; export function getConversationRouteErrorMessage(error: unknown): string { const status = error instanceof KiloChatApiError ? error.status : undefined; @@ -18,8 +19,31 @@ export function getConversationRouteErrorMessage(error: unknown): string { return 'Failed to load conversation'; } -export function shouldRenderConversationScreen( - detail: ConversationRouteDetailState -): detail is RenderableConversationRouteDetailState { - return !detail.isError && detail.data !== null && detail.data !== undefined; +export function getConversationRouteDecision({ + detail, + routeSandboxId, +}: { + detail: ConversationRouteDetailState; + routeSandboxId: string; +}): ConversationRouteDecision { + if (detail.isError) { + return 'error'; + } + if (detail.data === null || detail.data === undefined) { + return 'pending'; + } + if (conversationSandboxIdFromMembers(detail.data.members) !== routeSandboxId) { + return 'not-found'; + } + return 'ready'; +} + +export function shouldRenderConversationScreen({ + detail, + routeSandboxId, +}: { + detail: ConversationRouteDetailState; + routeSandboxId: string; +}): boolean { + return getConversationRouteDecision({ detail, routeSandboxId }) === 'ready'; } diff --git a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts index a1f174f87f..c602101ec4 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/[conversationId]/conversation-route-guard.ts @@ -1,4 +1,4 @@ -import type { ConversationMember } from '@kilocode/kilo-chat'; +import { conversationSandboxIdFromMembers, type ConversationMember } from '@kilocode/kilo-chat'; export type KiloChatInstanceRouteDecision = | 'pending' @@ -13,20 +13,7 @@ export type ConversationRouteDecision = | 'not-found' | 'redirect-no-instance'; -const kiloclawBotMemberPrefix = 'bot:kiloclaw:'; - -export function conversationSandboxIdFromMembers(members: ConversationMember[]): string | null { - for (const member of members) { - if (member.kind !== 'bot' || !member.id.startsWith(kiloclawBotMemberPrefix)) { - continue; - } - const sandboxId = member.id.slice(kiloclawBotMemberPrefix.length); - if (sandboxId.length > 0) { - return sandboxId; - } - } - return null; -} +export { conversationSandboxIdFromMembers }; export function kiloChatInstanceRouteDecision({ instanceStatus, diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index 8f6b3a7064..56ec2e3b4a 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -13,3 +13,4 @@ export type { KiloChatEvent, KiloChatEventName, KiloChatEventOf } from './events export * from './schemas'; export * from './webhook-schemas'; export * from './events'; +export * from './route-helpers'; diff --git a/packages/kilo-chat/src/route-helpers.ts b/packages/kilo-chat/src/route-helpers.ts new file mode 100644 index 0000000000..92ac48c8a9 --- /dev/null +++ b/packages/kilo-chat/src/route-helpers.ts @@ -0,0 +1,16 @@ +import type { ConversationMember } from './types'; + +const kiloclawBotMemberPrefix = 'bot:kiloclaw:'; + +export function conversationSandboxIdFromMembers(members: ConversationMember[]): string | null { + for (const member of members) { + if (member.kind !== 'bot' || !member.id.startsWith(kiloclawBotMemberPrefix)) { + continue; + } + const sandboxId = member.id.slice(kiloclawBotMemberPrefix.length); + if (sandboxId.length > 0) { + return sandboxId; + } + } + return null; +} From 25d646ff917363c589a60ebfe6c0cd79511d9c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:38:09 +0200 Subject: [PATCH 219/289] fix(mobile): persist focused kilo chat sandbox --- .../app/(app)/chat/[sandbox-id]/_layout.tsx | 19 +++++++- .../chat-sandbox-layout-subscription.test.ts | 12 +++++ .../src/lib/last-active-instance.test.ts | 45 +++++++++++++++++++ apps/mobile/src/lib/last-active-instance.ts | 5 +++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/last-active-instance.test.ts diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx index 050c49ef65..779ef42566 100644 --- a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx @@ -1,6 +1,8 @@ -import { Stack, useLocalSearchParams } from 'expo-router'; +import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback } from 'react'; import { useInstanceEventSubscription } from '@/components/kilo-chat/hooks/use-instance-event-subscription'; +import { setLastActiveInstance } from '@/lib/last-active-instance'; export function ChatSandboxInstanceEventSubscriptionMount() { const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); @@ -8,10 +10,25 @@ export function ChatSandboxInstanceEventSubscriptionMount() { return null; } +export function ChatSandboxLastActiveInstanceMount() { + const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); + + useFocusEffect( + useCallback(() => { + if (sandboxId) { + void setLastActiveInstance(sandboxId); + } + }, [sandboxId]) + ); + + return null; +} + export default function ChatSandboxLayout() { return ( <> + ); diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts index 4d69d2131d..59effc5fea 100644 --- a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts @@ -45,6 +45,14 @@ vi.mock('react', async () => { vi.mock('expo-router', () => ({ Stack: () => null, + useFocusEffect: (effect: ReactModule.EffectCallback) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + testState.cleanups.push(() => { + void cleanup(); + }); + } + }, useLocalSearchParams: () => ({ 'sandbox-id': testState.sandboxId }), })); @@ -79,6 +87,10 @@ vi.mock('@/components/kilo-chat/hooks/use-kilo-chat-client', () => ({ useKiloChatClient: () => ({}), })); +vi.mock('@/lib/last-active-instance', () => ({ + setLastActiveInstance: vi.fn(), +})); + function recordCleanup() { testState.cleanupCalls += 1; } diff --git a/apps/mobile/src/lib/last-active-instance.test.ts b/apps/mobile/src/lib/last-active-instance.test.ts new file mode 100644 index 0000000000..8d1addd581 --- /dev/null +++ b/apps/mobile/src/lib/last-active-instance.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getItemAsync: vi.fn<() => Promise>(), + setItemAsync: vi.fn<(key: string, value: string) => Promise>(), +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: mocks.getItemAsync, + setItemAsync: mocks.setItemAsync, +})); + +vi.mock('@/lib/storage-keys', () => ({ + LAST_ACTIVE_INSTANCE_KEY: 'last-active-chat-instance', +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('last active instance', () => { + it('updates the in-memory fallback before persisting the sandbox id', async () => { + mocks.setItemAsync.mockResolvedValue(undefined); + const { getLastActiveInstance, setLastActiveInstance } = await import('./last-active-instance'); + + const write = setLastActiveInstance('sandbox-b'); + + expect(getLastActiveInstance()).toBe('sandbox-b'); + await write; + expect(mocks.setItemAsync).toHaveBeenCalledWith('last-active-chat-instance', 'sandbox-b'); + }); + + it('keeps an explicitly focused sandbox ahead of the initial load fallback', async () => { + mocks.getItemAsync.mockResolvedValue('sandbox-a'); + mocks.setItemAsync.mockResolvedValue(undefined); + const { getLastActiveInstance, loadLastActiveInstance, setLastActiveInstance } = + await import('./last-active-instance'); + + await setLastActiveInstance('sandbox-b'); + await loadLastActiveInstance(); + + expect(getLastActiveInstance()).toBe('sandbox-b'); + }); +}); diff --git a/apps/mobile/src/lib/last-active-instance.ts b/apps/mobile/src/lib/last-active-instance.ts index 8929829b44..4286a78962 100644 --- a/apps/mobile/src/lib/last-active-instance.ts +++ b/apps/mobile/src/lib/last-active-instance.ts @@ -12,3 +12,8 @@ export async function loadLastActiveInstance(): Promise { export function getLastActiveInstance(): string | null { return cached; } + +export async function setLastActiveInstance(sandboxId: string): Promise { + cached = sandboxId; + await SecureStore.setItemAsync(LAST_ACTIVE_INSTANCE_KEY, sandboxId); +} From 8a02d6b992eb78f8d048756316fc800c6627315b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:39:12 +0200 Subject: [PATCH 220/289] fix(kilo-chat): retry HTTP calls after auth refresh --- .../kilo-chat/kilo-chat-provider.tsx | 4 ++ apps/web/src/contexts/EventServiceContext.tsx | 4 ++ packages/kilo-chat/src/client.ts | 34 +++++++++ packages/kilo-chat/src/types.ts | 1 + packages/kilo-chat/test/client.test.ts | 70 +++++++++++++++++++ 5 files changed, 113 insertions(+) diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx index 147fee52bd..3335ab9d6d 100644 --- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx +++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx @@ -37,6 +37,10 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) { eventService, baseUrl: KILO_CHAT_URL, getToken, + onUnauthorized: () => { + clearKiloChatTokenCache(); + return 'retry'; + }, }); return { eventService, kiloChatClient }; }); diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx index fdbb075507..8a9b99b66e 100644 --- a/apps/web/src/contexts/EventServiceContext.tsx +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -46,6 +46,10 @@ export function EventServiceProvider({ children }: EventServiceProviderProps) { eventService, baseUrl: KILO_CHAT_URL, getToken: getKiloChatToken, + onUnauthorized: () => { + clearKiloChatToken(); + return 'retry'; + }, }), [eventService] ); diff --git a/packages/kilo-chat/src/client.ts b/packages/kilo-chat/src/client.ts index ad83bf6b5e..a94e646d60 100644 --- a/packages/kilo-chat/src/client.ts +++ b/packages/kilo-chat/src/client.ts @@ -70,6 +70,7 @@ export class KiloChatClient { private readonly es: KiloChatClientConfig['eventService']; private readonly baseUrl: string; private readonly getToken: () => Promise; + private readonly onUnauthorized: KiloChatClientConfig['onUnauthorized']; private readonly fetchFn: typeof globalThis.fetch; // Per-conversation send queues. Each sendMessage call chains onto the tail // of its conversation's queue so concurrent callers cannot race ahead of @@ -82,6 +83,7 @@ export class KiloChatClient { this.es = config.eventService; this.baseUrl = config.baseUrl; this.getToken = config.getToken; + this.onUnauthorized = config.onUnauthorized; this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis); } @@ -395,6 +397,38 @@ export class KiloChatClient { query?: Record; schema: z.ZodType; } + ): Promise { + try { + return await this.httpRequestOnce(path, opts); + } catch (err) { + const onUnauthorized = this.onUnauthorized; + if (!this.shouldRecoverFromUnauthorized(err) || onUnauthorized === undefined) { + throw err; + } + const decision = await onUnauthorized(); + if (decision !== 'retry') { + throw err; + } + return this.httpRequestOnce(path, opts); + } + } + + private shouldRecoverFromUnauthorized(err: unknown): err is KiloChatApiError { + return ( + this.onUnauthorized !== undefined && + err instanceof KiloChatApiError && + (err.status === 401 || err.status === 403) + ); + } + + private async httpRequestOnce( + path: string, + opts: { + method?: string; + body?: unknown; + query?: Record; + schema: z.ZodType; + } ): Promise { const token = await this.getToken(); let url = `${this.baseUrl}${path}`; diff --git a/packages/kilo-chat/src/types.ts b/packages/kilo-chat/src/types.ts index 84699c0b98..bbdd53618d 100644 --- a/packages/kilo-chat/src/types.ts +++ b/packages/kilo-chat/src/types.ts @@ -67,6 +67,7 @@ export type KiloChatClientConfig = { eventService: EventServiceClient; baseUrl: string; getToken: () => Promise; + onUnauthorized?: () => Promise<'retry' | 'stop'> | 'retry' | 'stop'; fetch?: typeof globalThis.fetch; }; diff --git a/packages/kilo-chat/test/client.test.ts b/packages/kilo-chat/test/client.test.ts index ad7c158474..32a9fd7f35 100644 --- a/packages/kilo-chat/test/client.test.ts +++ b/packages/kilo-chat/test/client.test.ts @@ -52,6 +52,76 @@ describe('KiloChatClient', () => { ); expect(res).toEqual({ conversations: [], hasMore: false, nextCursor: null }); }); + + it('clears stale auth and retries one HTTP request after a 401', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'stale token' }), { status: 401 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + conversations: [], + hasMore: false, + nextCursor: null, + }), + { status: 200 } + ) + ); + const getToken = vi.fn<() => Promise>(); + getToken.mockResolvedValueOnce('stale-token'); + getToken.mockResolvedValueOnce('fresh-token'); + const onUnauthorized = vi.fn<() => 'retry'>(() => 'retry'); + const client = new KiloChatClient({ + ...createMockConfig(fetch), + getToken, + onUnauthorized, + }); + + await expect(client.listConversations()).resolves.toEqual({ + conversations: [], + hasMore: false, + nextCursor: null, + }); + + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(getToken).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://chat.example.com/v1/conversations', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer stale-token' }), + }) + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + 'https://chat.example.com/v1/conversations', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer fresh-token' }), + }) + ); + }); + + it('does not loop when the unauthorized retry also fails', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'stale token' }), { status: 401 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'still stale' }), { status: 401 }) + ); + const onUnauthorized = vi.fn<() => 'retry'>(() => 'retry'); + const client = new KiloChatClient({ + ...createMockConfig(fetch), + onUnauthorized, + }); + + await expect(client.listConversations()).rejects.toMatchObject({ status: 401 }); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + }); }); describe('getConversation', () => { From a26d7712ec103be20e9bbcd8e9198babc1a29989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:39:37 +0200 Subject: [PATCH 221/289] fix(event-service): stop exhausted auth reconnects --- .../src/__tests__/client.test.ts | 33 +++++++++++++++++++ packages/event-service/src/client.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/event-service/src/__tests__/client.test.ts b/packages/event-service/src/__tests__/client.test.ts index f5a0212f62..cd885500eb 100644 --- a/packages/event-service/src/__tests__/client.test.ts +++ b/packages/event-service/src/__tests__/client.test.ts @@ -311,6 +311,39 @@ describe('EventServiceClient', () => { } }); + it('stops after the single unauthorized retry is exhausted', async () => { + vi.useFakeTimers(); + try { + const retryAuth = (): 'retry' => 'retry'; + const onUnauthorized = vi.fn(retryAuth); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })) + ); + + const client = new EventServiceClient({ + url: 'ws://localhost:8080', + getToken: () => Promise.resolve('h.p.s'), + onUnauthorized, + }); + + await client.connect(); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(allMockWs).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(60_000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(allMockWs).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + it('keeps reconnecting after repeated pre-open failures', async () => { vi.useFakeTimers(); const reconnectDelay = vi.spyOn(Math, 'random').mockReturnValue(1); diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index e6b506dd7b..61c2f6f1ee 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -116,7 +116,8 @@ export class EventServiceClient { } if (this.authRecoveryAttempts >= MAX_AUTH_RECOVERY_ATTEMPTS) { - return false; + this.stopAfterUnauthorized(); + return true; } const decision = await this.onUnauthorized(); From c20e15fb6ddfb1817157ccb1e2882e9c9484ae66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:41:36 +0200 Subject: [PATCH 222/289] fix(notifications): retry transient Expo tickets --- .../src/__tests__/dispatch-push.test.ts | 40 ++++++++++++ .../notifications/src/lib/expo-push.test.ts | 59 +++++++++++++++++- services/notifications/src/lib/expo-push.ts | 62 ++++++++++++------- .../notifications/src/queue-consumer.test.ts | 12 +--- services/notifications/src/queue-consumer.ts | 3 - 5 files changed, 139 insertions(+), 37 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index a4114a32c0..ca123d28fc 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -402,6 +402,46 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(stored).toMatchObject({ stage: 'failed' }); }); + it('keeps retryable-only ticket failures non-terminal for a later retry', async () => { + installDbMock({ tokens: [{ user_id: 'user-retry-ticket-error', token: 'tok-rate-limited' }] }); + vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); + vi.mocked(sendPushNotifications).mockResolvedValueOnce({ + ticketTokenPairs: [], + staleTokens: [], + ticketErrors: [ + { + errorCode: 'MessageRateExceeded', + message: 'Rate limited', + retryable: true, + }, + ], + }); + const stub = getDO('user-retry-ticket-error'); + const input = baseInput({ + userId: 'user-retry-ticket-error', + idempotencyKey: 'k-retry-ticket-error', + }); + + const first = await stub.dispatchPush(input); + const second = await stub.dispatchPush(input); + + expect(first).toEqual({ + kind: 'failed', + error: 'Expo rejected 1 push ticket', + }); + expect(second).toEqual({ kind: 'delivered', tokenCount: 1 }); + expect(sendPushNotifications).toHaveBeenCalledTimes(2); + const [firstMessages] = vi.mocked(sendPushNotifications).mock.calls[0]; + const [secondMessages] = vi.mocked(sendPushNotifications).mock.calls[1]; + expect(firstMessages[0].badge).toBe(1); + expect(secondMessages[0].badge).toBe(1); + + const stored = await runInDurableObject(stub, async (_inst, state) => + state.storage.get<{ stage: string; ts: number }>('idem:k-retry-ticket-error') + ); + expect(stored).toMatchObject({ stage: 'delivered' }); + }); + it('accumulates bucket counts across deliveries and exposes total via badge', async () => { installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); diff --git a/services/notifications/src/lib/expo-push.test.ts b/services/notifications/src/lib/expo-push.test.ts index cbff52804f..74d16f5cbe 100644 --- a/services/notifications/src/lib/expo-push.test.ts +++ b/services/notifications/src/lib/expo-push.test.ts @@ -75,6 +75,63 @@ describe('sendPushNotifications', () => { }); }); + it('retries retryable ticket errors without resending accepted tokens', async () => { + const rateLimitedMessage: ExpoPushMessage = { + to: 'ExponentPushToken[token-2]', + title: 'Title', + body: 'Body', + }; + sendPushNotificationsAsync + .mockResolvedValueOnce([ + { status: 'ok', id: 'ticket-1' }, + { + status: 'error', + message: 'Rate exceeded', + details: { error: 'MessageRateExceeded' }, + }, + ]) + .mockResolvedValueOnce([{ status: 'ok', id: 'ticket-2' }]); + + const result = await sendPushNotifications([message, rateLimitedMessage], 'access-token'); + + expect(sendPushNotificationsAsync).toHaveBeenCalledTimes(2); + expect(sendPushNotificationsAsync).toHaveBeenNthCalledWith(1, [message, rateLimitedMessage]); + expect(sendPushNotificationsAsync).toHaveBeenNthCalledWith(2, [rateLimitedMessage]); + expect(result).toEqual({ + ticketTokenPairs: [ + { ticketId: 'ticket-1', token: 'ExponentPushToken[token-1]' }, + { ticketId: 'ticket-2', token: 'ExponentPushToken[token-2]' }, + ], + staleTokens: [], + ticketErrors: [], + }); + }); + + it('surfaces retryable ticket errors after the bounded retry budget', async () => { + sendPushNotificationsAsync.mockResolvedValue([ + { + status: 'error', + message: 'Rate exceeded', + details: { error: 'MessageRateExceeded' }, + }, + ]); + + const result = await sendPushNotifications([message], 'access-token'); + + expect(sendPushNotificationsAsync).toHaveBeenCalledTimes(3); + expect(result).toEqual({ + ticketTokenPairs: [], + staleTokens: [], + ticketErrors: [ + { + errorCode: 'MessageRateExceeded', + message: 'Rate exceeded', + retryable: true, + }, + ], + }); + }); + it('surfaces non-stale ticket errors', async () => { sendPushNotificationsAsync.mockResolvedValueOnce([ { @@ -144,13 +201,11 @@ describe('checkPushReceipts', () => { ticketId: 'ticket-terminal', errorCode: 'InvalidCredentials', message: 'Invalid credentials', - retryable: false, }, { ticketId: 'ticket-retryable', errorCode: 'MessageRateExceeded', message: 'Rate exceeded', - retryable: true, }, ], }); diff --git a/services/notifications/src/lib/expo-push.ts b/services/notifications/src/lib/expo-push.ts index 9e2310560b..81802adf28 100644 --- a/services/notifications/src/lib/expo-push.ts +++ b/services/notifications/src/lib/expo-push.ts @@ -18,7 +18,6 @@ export type PushReceiptError = { ticketId: string; errorCode: string | undefined; message: string; - retryable: boolean; }; export type SendResult = { @@ -70,10 +69,6 @@ function isRetryableTicketError(errorCode: string | undefined): boolean { return true; } -function isRetryableReceiptError(errorCode: string | undefined): boolean { - return isRetryableTicketError(errorCode); -} - export async function sendPushNotifications( messages: ExpoPushMessage[], accessToken: string @@ -88,24 +83,46 @@ export async function sendPushNotifications( const ticketErrors: PushTicketError[] = []; for (const chunk of chunks) { - const tickets = await sendChunkWithTransientRetry(expo, chunk); - - for (let i = 0; i < tickets.length; i++) { - const ticket = tickets[i]; - const to = chunk[i].to; - const token = typeof to === 'string' ? to : to[0]; - if (ticket.status === 'ok') { - ticketTokenPairs.push({ ticketId: ticket.id, token }); - } else if (ticket.details?.error === 'DeviceNotRegistered') { - staleTokens.push(token); - } else { - const errorCode = ticket.details?.error; - ticketErrors.push({ - errorCode, - message: ticket.message, - retryable: isRetryableTicketError(errorCode), - }); + let pendingChunk = chunk; + + for (let attempt = 0; ; attempt++) { + const tickets = await sendChunkWithTransientRetry(expo, pendingChunk); + const retryChunk: ExpoPushMessage[] = []; + + for (let i = 0; i < tickets.length; i++) { + const ticket = tickets[i]; + const message = pendingChunk[i]; + const to = message.to; + const token = typeof to === 'string' ? to : to[0]; + if (ticket.status === 'ok') { + ticketTokenPairs.push({ ticketId: ticket.id, token }); + } else if (ticket.details?.error === 'DeviceNotRegistered') { + staleTokens.push(token); + } else { + const errorCode = ticket.details?.error; + const retryable = isRetryableTicketError(errorCode); + const retryDelayMs = TRANSIENT_SEND_RETRY_DELAYS_MS[attempt]; + if (retryable && retryDelayMs !== undefined) { + retryChunk.push(message); + } else { + ticketErrors.push({ + errorCode, + message: ticket.message, + retryable, + }); + } + } + } + + if (retryChunk.length === 0) { + break; + } + const retryDelayMs = TRANSIENT_SEND_RETRY_DELAYS_MS[attempt]; + if (retryDelayMs === undefined) { + break; } + await sleep(retryDelayMs); + pendingChunk = retryChunk; } } @@ -140,7 +157,6 @@ export async function checkPushReceipts( ticketId, errorCode, message: receipt.message, - retryable: isRetryableReceiptError(errorCode), }); } } diff --git a/services/notifications/src/queue-consumer.test.ts b/services/notifications/src/queue-consumer.test.ts index f6668fc6c8..3e7b55f1a5 100644 --- a/services/notifications/src/queue-consumer.test.ts +++ b/services/notifications/src/queue-consumer.test.ts @@ -59,20 +59,18 @@ describe('receipt queue consumer', () => { ticketId: 'ticket-terminal', errorCode: 'InvalidCredentials', message: 'Invalid credentials', - retryable: false, }, { - ticketId: 'ticket-retryable', + ticketId: 'ticket-rate-exceeded', errorCode: 'MessageRateExceeded', message: 'Rate exceeded', - retryable: true, }, ], }); const { batch, ack, retry } = fakeBatch({ ticketTokenPairs: [ { ticketId: 'ticket-terminal', token: 'ExponentPushToken[terminal]' }, - { ticketId: 'ticket-retryable', token: 'ExponentPushToken[retryable]' }, + { ticketId: 'ticket-rate-exceeded', token: 'ExponentPushToken[rate-exceeded]' }, ], }); @@ -82,20 +80,16 @@ describe('receipt queue consumer', () => { expect(retry).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith('Receipt check returned non-stale Expo receipt error(s)', { errorCount: 2, - retryableCount: 1, - terminalCount: 1, errors: [ { ticketId: 'ticket-terminal', errorCode: 'InvalidCredentials', message: 'Invalid credentials', - retryable: false, }, { - ticketId: 'ticket-retryable', + ticketId: 'ticket-rate-exceeded', errorCode: 'MessageRateExceeded', message: 'Rate exceeded', - retryable: true, }, ], }); diff --git a/services/notifications/src/queue-consumer.ts b/services/notifications/src/queue-consumer.ts index c485f4d1dd..25ff47bbb2 100644 --- a/services/notifications/src/queue-consumer.ts +++ b/services/notifications/src/queue-consumer.ts @@ -40,13 +40,10 @@ async function processReceiptCheck(env: Env, message: ReceiptCheckMessage): Prom if (receiptErrors.length > 0) { console.warn('Receipt check returned non-stale Expo receipt error(s)', { errorCount: receiptErrors.length, - retryableCount: receiptErrors.filter(error => error.retryable).length, - terminalCount: receiptErrors.filter(error => !error.retryable).length, errors: receiptErrors.map(error => ({ ticketId: error.ticketId, errorCode: error.errorCode, message: error.message, - retryable: error.retryable, })), }); } From 3f3c8ef8a41847b351aa2ac2be6402d7448f1164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:42:35 +0200 Subject: [PATCH 223/289] fix(env-sync): prefer exact secret sources --- dev/local/env-sync/plan.test.ts | 2 +- dev/local/env-sync/plan.ts | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 40ddcd582c..ef99597d11 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -233,7 +233,7 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var test('auto-creates Secrets Store binding from exact suffixed local dev vars before base fallback', () => { const repo = createRepo({ - '.env.local': '', + '.env.local': 'GATEWAY_TOKEN_SECRET=base-secret\n', 'services/kiloclaw/.dev.vars.example': [ 'GATEWAY_TOKEN_SECRET=dev-gateway-secret-kiloclaw', 'GATEWAY_TOKEN_SECRET_DEV=dev-gateway-secret-kiloclaw-dev', diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index 1ba7af8982..c660708603 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -260,12 +260,22 @@ function resolveSecretStoreSource( localSecretSources: Map ): { sourceKey: string; value: string } | undefined { const baseKey = secretName.replace(/_(PROD|DEV)$/, ''); - const envLocalValue = envLocal.get(baseKey); - if (envLocalValue) { - return { sourceKey: baseKey, value: envLocalValue }; + const exactLocalSource = localSecretSources.get(secretName); + if (exactLocalSource) { + return exactLocalSource; } - return localSecretSources.get(secretName) ?? localSecretSources.get(baseKey); + const exactEnvLocalValue = envLocal.get(secretName); + if (exactEnvLocalValue) { + return { sourceKey: secretName, value: exactEnvLocalValue }; + } + + const baseEnvLocalValue = envLocal.get(baseKey); + if (baseEnvLocalValue) { + return { sourceKey: baseKey, value: baseEnvLocalValue }; + } + + return localSecretSources.get(baseKey); } function collectLocalSecretSources( From 93b2f9d905cf4480a88405965f801748af66abae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:43:26 +0200 Subject: [PATCH 224/289] fix(env-sync): skip exec during secret discovery --- dev/local/env-sync/plan.test.ts | 44 +++++++++++++++++++++++++++++++++ dev/local/env-sync/plan.ts | 25 ++++++++++--------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index ef99597d11..f3984e37bb 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -273,6 +273,50 @@ test('auto-creates Secrets Store binding from exact suffixed local dev vars befo } }); +test('does not execute unrelated @exec annotations while discovering filtered secret sources', () => { + const repo = createRepo({ + '.env.local': '', + 'services/kiloclaw/.dev.vars.example': [ + '# @exec node -e console.log("exec-secret")', + 'DEV_CREATOR=', + '', + ].join('\n'), + 'services/kilo-chat/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/kilo-chat/.dev.vars.example': 'KILO_CHAT_URL=http://localhost:8787\n', + 'services/kilo-chat/wrangler.jsonc': `{ + "secrets_store_secrets": [ + { + "binding": "DEV_CREATOR", + "store_id": "store-id", + "secret_name": "DEV_CREATOR" + } + ] + }`, + }); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['kilo-chat'])); + assert.equal(plan.missingEnvLocal, false); + assert.deepEqual(plan.secretStoreAutoCreates, []); + assert.deepEqual(plan.secretStoreWarnings, [ + { + workerDir: 'services/kilo-chat', + bindings: [ + { + binding: 'DEV_CREATOR', + store_id: 'store-id', + secret_name: 'DEV_CREATOR', + }, + ], + }, + ]); + assert.deepEqual(plan.execWarnings, []); + }); + } finally { + repo.cleanup(); + } +}); + test('keeps .env.local values ahead of wrangler vars for local overrides', () => { const repo = createCloudAgentNextRepo({ envLocal: 'R2_ATTACHMENTS_BUCKET=local-attachments\n', diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index c660708603..077e41e9d7 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -288,11 +288,25 @@ function collectLocalSecretSources( const sources = new Map(); for (const workerDir of workerDirs) { + const devVarsPath = path.join(repoRoot, workerDir, '.dev.vars'); + const localVars = readEnvFile(devVarsPath); + for (const [key, value] of localVars) { + if (value) { + sources.set(key, { + sourceKey: `${workerDir}/.dev.vars:${key}`, + value, + }); + } + } + const examplePath = path.join(repoRoot, workerDir, '.dev.vars.example'); const serviceUsesLanIp = dirUsesLanIp.get(workerDir) ?? false; if (fs.existsSync(examplePath)) { const entries = parseExampleFile(fs.readFileSync(examplePath, 'utf-8')); for (const entry of entries) { + if (entry.annotation.type === 'exec') { + continue; + } const { value } = resolveAnnotatedValue( entry.key, entry, @@ -308,17 +322,6 @@ function collectLocalSecretSources( } } } - - const devVarsPath = path.join(repoRoot, workerDir, '.dev.vars'); - const localVars = readEnvFile(devVarsPath); - for (const [key, value] of localVars) { - if (value) { - sources.set(key, { - sourceKey: `${workerDir}/.dev.vars:${key}`, - value, - }); - } - } } return sources; From 34ed5ed0c2aba8120cc046f3c9440f0032d34112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 12:52:09 +0200 Subject: [PATCH 225/289] fix(mobile): keep chat state types internal --- apps/mobile/src/components/kilo-chat/conversation-list-state.ts | 2 +- .../mobile/src/components/kilo-chat/conversation-route-state.ts | 2 +- apps/mobile/src/components/kilo-chat/message-history-state.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-state.ts b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts index 998a2a639c..4d5386019b 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-state.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-list-state.ts @@ -1,4 +1,4 @@ -export type ConversationListContentState = 'loading' | 'error' | 'ready'; +type ConversationListContentState = 'loading' | 'error' | 'ready'; export function getConversationListContentState({ isPending, diff --git a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts index 232d388773..26d1d6f1c1 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-route-state.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-route-state.ts @@ -9,7 +9,7 @@ type ConversationRouteDetailState = { isError: boolean; }; -export type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found'; +type ConversationRouteDecision = 'pending' | 'ready' | 'error' | 'not-found'; export function getConversationRouteErrorMessage(error: unknown): string { const status = error instanceof KiloChatApiError ? error.status : undefined; diff --git a/apps/mobile/src/components/kilo-chat/message-history-state.ts b/apps/mobile/src/components/kilo-chat/message-history-state.ts index d8c49a6cbe..dae35d0d87 100644 --- a/apps/mobile/src/components/kilo-chat/message-history-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-history-state.ts @@ -1,4 +1,4 @@ -export type MessageHistoryContentState = 'loading' | 'error' | 'ready'; +type MessageHistoryContentState = 'loading' | 'error' | 'ready'; export function getMessageHistoryContentState({ isPending, From 4c938908563d0cb10dea85b29982851e954134b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 13:59:43 +0200 Subject: [PATCH 226/289] refactor(kilo-chat): share bearer auth verification --- packages/worker-utils/package.json | 3 + .../worker-utils/src/kilo-token-auth.test.ts | 70 +++++++++++++++++++ packages/worker-utils/src/kilo-token-auth.ts | 62 ++++++++++++++++ pnpm-lock.yaml | 6 ++ .../event-service/src/__tests__/auth.test.ts | 6 +- services/event-service/src/auth.ts | 37 ++-------- services/kilo-chat/src/__tests__/auth.test.ts | 8 ++- services/kilo-chat/src/auth.ts | 35 +++------- .../notifications/src/__tests__/auth.test.ts | 8 ++- services/notifications/src/auth.ts | 35 +++------- 10 files changed, 186 insertions(+), 84 deletions(-) create mode 100644 packages/worker-utils/src/kilo-token-auth.test.ts create mode 100644 packages/worker-utils/src/kilo-token-auth.ts diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 5a9540a46b..f1f01ce00f 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./instance-id": "./src/instance-id.ts", + "./kilo-token-auth": "./src/kilo-token-auth.ts", "./redact-headers": "./src/redact-headers.ts", "./kiloclaw-billing-observability": "./src/kiloclaw-billing-observability.ts" }, @@ -16,7 +17,9 @@ "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/worker-utils/src" }, "dependencies": { + "@kilocode/db": "workspace:*", "aws4fetch": "catalog:", + "drizzle-orm": "catalog:", "hono": "catalog:", "jose": "catalog:", "zod": "catalog:" diff --git a/packages/worker-utils/src/kilo-token-auth.test.ts b/packages/worker-utils/src/kilo-token-auth.test.ts new file mode 100644 index 0000000000..484eb46534 --- /dev/null +++ b/packages/worker-utils/src/kilo-token-auth.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { clearSecretCacheForTest } from './cached-secret'; +import { signKiloToken } from './kilo-token'; +import { verifyKiloBearerAgainstCurrentPepper } from './kilo-token-auth'; + +const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256'; + +const currentPepperByUserId = new Map(); + +async function getUserPepper(_connectionString: string, userId: string) { + return currentPepperByUserId.has(userId) ? currentPepperByUserId.get(userId) : undefined; +} + +async function signToken(params: { + pepper: string | null; + tokenSource: 'kilo-chat' | 'cloud-agent'; +}) { + return signKiloToken({ + userId: 'user-xyz-789', + pepper: params.pepper, + secret: TEST_JWT_SECRET, + expiresInSeconds: 3600, + env: 'production', + extra: { tokenSource: params.tokenSource }, + }); +} + +function verifyToken(token: string | null) { + return verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: { get: async () => TEST_JWT_SECRET }, + workerEnv: 'production', + connectionString: 'postgres://test', + getUserPepper, + }); +} + +describe('verifyKiloBearerAgainstCurrentPepper', () => { + beforeEach(() => { + clearSecretCacheForTest(); + currentPepperByUserId.clear(); + currentPepperByUserId.set('user-xyz-789', 'pepper-current'); + }); + + it('accepts a token with the current user pepper', async () => { + const { token } = await signToken({ pepper: 'pepper-current', tokenSource: 'kilo-chat' }); + + await expect(verifyToken(token)).resolves.toEqual({ userId: 'user-xyz-789' }); + }); + + it('accepts valid tokens from any token source', async () => { + const { token } = await signToken({ pepper: 'pepper-current', tokenSource: 'cloud-agent' }); + + await expect(verifyToken(token)).resolves.toEqual({ userId: 'user-xyz-789' }); + }); + + it('rejects tokens for missing users', async () => { + currentPepperByUserId.clear(); + const { token } = await signToken({ pepper: 'pepper-current', tokenSource: 'kilo-chat' }); + + await expect(verifyToken(token)).resolves.toBeNull(); + }); + + it('rejects tokens with stale peppers', async () => { + const { token } = await signToken({ pepper: 'pepper-stale', tokenSource: 'kilo-chat' }); + + await expect(verifyToken(token)).resolves.toBeNull(); + }); +}); diff --git a/packages/worker-utils/src/kilo-token-auth.ts b/packages/worker-utils/src/kilo-token-auth.ts new file mode 100644 index 0000000000..7b6088a2fd --- /dev/null +++ b/packages/worker-utils/src/kilo-token-auth.ts @@ -0,0 +1,62 @@ +import { getWorkerDb } from '@kilocode/db/client'; +import { kilocode_users } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +import { getCachedSecret } from './cached-secret'; +import { verifyKiloToken } from './kilo-token'; + +export type KiloBearerAuthResult = { + userId: string; +}; + +export type KiloSecretBinding = { + get(): Promise; +}; + +export type GetKiloUserPepper = ( + connectionString: string, + userId: string +) => Promise; + +export async function findKiloUserPepper( + connectionString: string, + userId: string +): Promise { + const db = getWorkerDb(connectionString); + const rows = await db + .select({ api_token_pepper: kilocode_users.api_token_pepper }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)) + .limit(1); + const row = rows[0]; + return row ? (row.api_token_pepper ?? null) : undefined; +} + +export async function verifyKiloBearerAgainstCurrentPepper(params: { + token: string | null; + nextAuthSecret: KiloSecretBinding; + workerEnv: string; + connectionString: string; + getUserPepper?: GetKiloUserPepper; +}): Promise { + if (!params.token) return null; + + const getUserPepper = params.getUserPepper ?? findKiloUserPepper; + + try { + const secret = await getCachedSecret(params.nextAuthSecret, 'NEXTAUTH_SECRET'); + const payload = await verifyKiloToken(params.token, secret); + if (payload.env !== params.workerEnv) { + return null; + } + + const currentPepper = await getUserPepper(params.connectionString, payload.kiloUserId); + const tokenPepper = payload.apiTokenPepper ?? null; + if (currentPepper === undefined || currentPepper !== tokenPepper) { + return null; + } + return { userId: payload.kiloUserId }; + } catch { + return null; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f2ead480..8b13845014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,9 +1101,15 @@ importers: packages/worker-utils: dependencies: + '@kilocode/db': + specifier: workspace:* + version: link:../db aws4fetch: specifier: 'catalog:' version: 1.0.20 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.1(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.11)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.8 diff --git a/services/event-service/src/__tests__/auth.test.ts b/services/event-service/src/__tests__/auth.test.ts index 2615250605..c642ebd270 100644 --- a/services/event-service/src/__tests__/auth.test.ts +++ b/services/event-service/src/__tests__/auth.test.ts @@ -43,7 +43,7 @@ describe('authenticateToken', () => { await expect(authenticateToken(token, makeEnv())).resolves.toEqual({ userId: 'user-xyz-789' }); }); - it('rejects a valid Kilo JWT that is not scoped to kilo-chat', async () => { + it('authenticates a valid JWT from another token source', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', pepper: 'pepper-current', @@ -53,7 +53,9 @@ describe('authenticateToken', () => { extra: { tokenSource: 'cloud-agent' }, }); - await expect(authenticateToken(token, makeEnv())).resolves.toBeNull(); + await expect(authenticateToken(token, makeEnv())).resolves.toEqual({ + userId: 'user-xyz-789', + }); }); it('rejects a valid kilo-chat JWT with a stale pepper', async () => { diff --git a/services/event-service/src/auth.ts b/services/event-service/src/auth.ts index 4f4beb021a..f19d100801 100644 --- a/services/event-service/src/auth.ts +++ b/services/event-service/src/auth.ts @@ -1,39 +1,16 @@ -import { getWorkerDb } from '@kilocode/db/client'; -import { kilocode_users } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; -import { getCachedSecret, verifyKiloChatToken } from '@kilocode/worker-utils'; +import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; export type AuthResult = { userId: string }; export type AuthEnv = Pick; -async function findUserPepper( - connectionString: string, - userId: string -): Promise { - const db = getWorkerDb(connectionString); - const rows = await db - .select({ api_token_pepper: kilocode_users.api_token_pepper }) - .from(kilocode_users) - .where(eq(kilocode_users.id, userId)) - .limit(1); - const row = rows[0]; - return row ? (row.api_token_pepper ?? null) : undefined; -} - export async function authenticateToken( token: string | null, env: AuthEnv ): Promise { - if (!token) return null; - try { - const secret = await getCachedSecret(env.NEXTAUTH_SECRET, 'NEXTAUTH_SECRET'); - const payload = await verifyKiloChatToken(token, secret, env.WORKER_ENV); - const currentPepper = await findUserPepper(env.HYPERDRIVE.connectionString, payload.userId); - if (currentPepper === undefined || currentPepper !== payload.pepper) { - return null; - } - return { userId: payload.userId }; - } catch { - return null; - } + return verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: env.NEXTAUTH_SECRET, + workerEnv: env.WORKER_ENV, + connectionString: env.HYPERDRIVE.connectionString, + }); } diff --git a/services/kilo-chat/src/__tests__/auth.test.ts b/services/kilo-chat/src/__tests__/auth.test.ts index 170344892b..52d50337bd 100644 --- a/services/kilo-chat/src/__tests__/auth.test.ts +++ b/services/kilo-chat/src/__tests__/auth.test.ts @@ -70,7 +70,7 @@ describe('authMiddleware', () => { }); }); - it('returns 401 when the JWT is valid but not scoped to kilo-chat', async () => { + it('authenticates a valid JWT from another token source', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', pepper: 'pepper-current', @@ -84,7 +84,11 @@ describe('authMiddleware', () => { { headers: { authorization: `Bearer ${token}` } }, defaultEnv ); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + callerId: 'user-xyz-789', + callerKind: 'user', + }); }); it('returns 401 when the chat JWT has a stale pepper', async () => { diff --git a/services/kilo-chat/src/auth.ts b/services/kilo-chat/src/auth.ts index 6da2ccf21e..1be48bc694 100644 --- a/services/kilo-chat/src/auth.ts +++ b/services/kilo-chat/src/auth.ts @@ -1,8 +1,6 @@ import { createMiddleware } from 'hono/factory'; -import { getWorkerDb } from '@kilocode/db/client'; -import { kilocode_users } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; -import { extractBearerToken, getCachedSecret, verifyKiloChatToken } from '@kilocode/worker-utils'; +import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; +import { extractBearerToken } from '@kilocode/worker-utils'; import { logger } from './util/logger'; export type AuthContext = { @@ -10,20 +8,6 @@ export type AuthContext = { callerKind: 'user' | 'bot'; }; -async function findUserPepper( - connectionString: string, - userId: string -): Promise { - const db = getWorkerDb(connectionString); - const rows = await db - .select({ api_token_pepper: kilocode_users.api_token_pepper }) - .from(kilocode_users) - .where(eq(kilocode_users.id, userId)) - .limit(1); - const row = rows[0]; - return row ? (row.api_token_pepper ?? null) : undefined; -} - /** * Public HTTP auth for kilo-chat — humans only. The bearer is a Kilo JWT * verified with NEXTAUTH_SECRET. @@ -42,16 +26,19 @@ export const authMiddleware = createMiddleware<{ } try { - const jwtSecret = await getCachedSecret(c.env.NEXTAUTH_SECRET, 'NEXTAUTH_SECRET'); - const payload = await verifyKiloChatToken(token, jwtSecret, c.env.WORKER_ENV); - const currentPepper = await findUserPepper(c.env.HYPERDRIVE.connectionString, payload.userId); - if (currentPepper === undefined || currentPepper !== payload.pepper) { + const auth = await verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: c.env.NEXTAUTH_SECRET, + workerEnv: c.env.WORKER_ENV, + connectionString: c.env.HYPERDRIVE.connectionString, + }); + if (!auth) { return c.json({ error: 'Unauthorized' }, 401); } - c.set('callerId', payload.userId); + c.set('callerId', auth.userId); c.set('callerKind', 'user'); - logger.setTags({ callerId: payload.userId, callerKind: 'user' }); + logger.setTags({ callerId: auth.userId, callerKind: 'user' }); return next(); } catch { return c.json({ error: 'Unauthorized' }, 401); diff --git a/services/notifications/src/__tests__/auth.test.ts b/services/notifications/src/__tests__/auth.test.ts index 594230445a..6b31b780ed 100644 --- a/services/notifications/src/__tests__/auth.test.ts +++ b/services/notifications/src/__tests__/auth.test.ts @@ -70,7 +70,7 @@ describe('authMiddleware', () => { }); }); - it('returns 401 when the JWT is valid but not scoped to kilo-chat', async () => { + it('authenticates a valid JWT from another token source', async () => { const { token } = await signKiloToken({ userId: 'user-xyz-789', pepper: 'pepper-current', @@ -84,7 +84,11 @@ describe('authMiddleware', () => { { headers: { authorization: `Bearer ${token}` } }, defaultEnv ); - expect(res.status).toBe(401); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + callerId: 'user-xyz-789', + callerKind: 'user', + }); }); it('returns 401 when the chat JWT has a stale pepper', async () => { diff --git a/services/notifications/src/auth.ts b/services/notifications/src/auth.ts index af295886f7..5abd95b1a0 100644 --- a/services/notifications/src/auth.ts +++ b/services/notifications/src/auth.ts @@ -1,8 +1,6 @@ import { createMiddleware } from 'hono/factory'; -import { getWorkerDb } from '@kilocode/db/client'; -import { kilocode_users } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; -import { extractBearerToken, getCachedSecret, verifyKiloChatToken } from '@kilocode/worker-utils'; +import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; +import { extractBearerToken } from '@kilocode/worker-utils'; import { logger } from './util/logger'; export type AuthContext = { @@ -10,20 +8,6 @@ export type AuthContext = { callerKind: 'user'; }; -async function findUserPepper( - connectionString: string, - userId: string -): Promise { - const db = getWorkerDb(connectionString); - const rows = await db - .select({ api_token_pepper: kilocode_users.api_token_pepper }) - .from(kilocode_users) - .where(eq(kilocode_users.id, userId)) - .limit(1); - const row = rows[0]; - return row ? (row.api_token_pepper ?? null) : undefined; -} - /** * Public HTTP auth for the notifications worker — humans only. The bearer is * a Kilo JWT verified with NEXTAUTH_SECRET. @@ -41,16 +25,19 @@ export const authMiddleware = createMiddleware<{ } try { - const jwtSecret = await getCachedSecret(c.env.NEXTAUTH_SECRET, 'NEXTAUTH_SECRET'); - const payload = await verifyKiloChatToken(token, jwtSecret, c.env.WORKER_ENV); - const currentPepper = await findUserPepper(c.env.HYPERDRIVE.connectionString, payload.userId); - if (currentPepper === undefined || currentPepper !== payload.pepper) { + const auth = await verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: c.env.NEXTAUTH_SECRET, + workerEnv: c.env.WORKER_ENV, + connectionString: c.env.HYPERDRIVE.connectionString, + }); + if (!auth) { return c.json({ error: 'Unauthorized' }, 401); } - c.set('callerId', payload.userId); + c.set('callerId', auth.userId); c.set('callerKind', 'user'); - logger.setTags({ callerId: payload.userId, callerKind: 'user' }); + logger.setTags({ callerId: auth.userId, callerKind: 'user' }); return next(); } catch { return c.json({ error: 'Unauthorized' }, 401); From f260989c877c0b4a7e3eaeb902e1abb5eff3899c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 14:00:03 +0200 Subject: [PATCH 227/289] refactor(kilo-chat): share presence subscriptions --- .../kilo-chat/hooks/use-app-presence.ts | 3 +- .../hooks/use-conversation-presence.ts | 2 +- .../kilo-chat/hooks/use-instance-presence.ts | 2 +- .../kilo-chat/components/KiloChatLayout.tsx | 2 +- .../claw/kilo-chat/components/MessageArea.tsx | 2 +- apps/web/src/hooks/useInstancePresence.ts | 2 +- apps/web/src/hooks/usePlatformPresence.ts | 2 +- apps/web/src/hooks/usePresenceSubscription.ts | 23 ------- packages/kilo-chat-hooks/src/index.ts | 1 + .../src/use-presence-subscription.test.ts | 61 +++++++++++++++++++ .../src}/use-presence-subscription.ts | 6 +- 11 files changed, 71 insertions(+), 35 deletions(-) delete mode 100644 apps/web/src/hooks/usePresenceSubscription.ts create mode 100644 packages/kilo-chat-hooks/src/use-presence-subscription.test.ts rename {apps/mobile/src/components/kilo-chat/hooks => packages/kilo-chat-hooks/src}/use-presence-subscription.ts (72%) diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts index 79389de2a0..930cba5e8c 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts @@ -2,8 +2,7 @@ import { useEffect, useState } from 'react'; import { AppState } from 'react-native'; import { presenceContextForPlatform } from '@kilocode/event-service'; - -import { usePresenceSubscription } from './use-presence-subscription'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; export function useAppPresence() { const [active, setActive] = useState(AppState.currentState === 'active'); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts index ed2b68dacc..bc58d37848 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts @@ -1,7 +1,7 @@ import { presenceContextForConversation } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { useAppActiveAndFocused } from './use-app-active-and-focused'; -import { usePresenceSubscription } from './use-presence-subscription'; export function useConversationPresence( sandboxId: string | undefined, diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts index 1c04d9eaca..204f0d89ff 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts @@ -1,7 +1,7 @@ import { presenceContextForInstance } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { useAppActiveAndFocused } from './use-app-active-and-focused'; -import { usePresenceSubscription } from './use-presence-subscription'; export function useInstancePresence(sandboxId: string | undefined) { const activeAndFocused = useAppActiveAndFocused(); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 5cb7ff2346..bd39ff8489 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -5,10 +5,10 @@ import { useRouter, useParams } from 'next/navigation'; import { toast } from 'sonner'; import { useQueryClient } from '@tanstack/react-query'; import { formatKiloChatError } from '@kilocode/kilo-chat'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; import { kiloclawInstanceContext } from '@kilocode/event-service'; -import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useEventServiceClient } from '@/contexts/EventServiceContext'; import { useConversations, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 615ff9d44a..cef6ca9f0c 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -24,7 +24,6 @@ import { kiloclawConversationContext, presenceContextForConversation, } from '@kilocode/event-service'; -import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useDocumentVisible } from '@/hooks/useDocumentVisible'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { @@ -50,6 +49,7 @@ import { clearMarkReadRetry, createMarkReadRetryState, scheduleMarkReadRetry, + usePresenceSubscription, } from '@kilocode/kilo-chat-hooks'; import { KiloChatApiError, diff --git a/apps/web/src/hooks/useInstancePresence.ts b/apps/web/src/hooks/useInstancePresence.ts index 085e28f2e2..df93e921b4 100644 --- a/apps/web/src/hooks/useInstancePresence.ts +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -1,9 +1,9 @@ 'use client'; import { presenceContextForInstance } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { useDocumentVisible } from './useDocumentVisible'; -import { usePresenceSubscription } from './usePresenceSubscription'; export function useInstancePresence(sandboxId: string | undefined, enabled = true) { const visible = useDocumentVisible(); diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts index 86cb6fa2e8..fd0fc7135c 100644 --- a/apps/web/src/hooks/usePlatformPresence.ts +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -1,9 +1,9 @@ 'use client'; import { presenceContextForPlatform } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks'; import { useDocumentVisible } from './useDocumentVisible'; -import { usePresenceSubscription } from './usePresenceSubscription'; export function usePlatformPresence() { const visible = useDocumentVisible(); diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts deleted file mode 100644 index 56bb55a381..0000000000 --- a/apps/web/src/hooks/usePresenceSubscription.ts +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useEventServiceClient } from '@/contexts/EventServiceContext'; - -/** - * Subscribes to a single presence/event-service context for the lifetime of - * the calling component. Bails out when `active` is false so callers can - * gate the subscription on, e.g., feature flags or page visibility. - * - * Reads the global `EventServiceClient` from `EventServiceProvider`, mounted - * in `(app)/layout.tsx` for every authenticated route. - */ -export function usePresenceSubscription(context: string | null, active: boolean) { - const { eventService } = useEventServiceClient(); - useEffect(() => { - if (!active || context === null) return; - eventService.subscribe([context]); - return () => { - eventService.unsubscribe([context]); - }; - }, [eventService, context, active]); -} diff --git a/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts index c256b901f3..683249011d 100644 --- a/packages/kilo-chat-hooks/src/index.ts +++ b/packages/kilo-chat-hooks/src/index.ts @@ -5,3 +5,4 @@ export * from './query-keys'; export * from './use-bot-status'; export * from './use-conversations'; export * from './use-messages'; +export * from './use-presence-subscription'; diff --git a/packages/kilo-chat-hooks/src/use-presence-subscription.test.ts b/packages/kilo-chat-hooks/src/use-presence-subscription.test.ts new file mode 100644 index 0000000000..9920979fb0 --- /dev/null +++ b/packages/kilo-chat-hooks/src/use-presence-subscription.test.ts @@ -0,0 +1,61 @@ +import type * as ReactModule from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const testState = vi.hoisted(() => ({ + cleanups: [] as (() => void)[], + subscribe: vi.fn<(contexts: string[]) => void>(), + unsubscribe: vi.fn<(contexts: string[]) => void>(), +})); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useEffect: (effect: ReactModule.EffectCallback) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + testState.cleanups.push(cleanup); + } + }, + }; +}); + +vi.mock('./context', () => ({ + useEventServiceClient: () => ({ + subscribe: testState.subscribe, + unsubscribe: testState.unsubscribe, + }), +})); + +import { usePresenceSubscription } from './use-presence-subscription'; + +describe('usePresenceSubscription', () => { + beforeEach(() => { + testState.cleanups = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + for (const cleanup of testState.cleanups) { + cleanup(); + } + }); + + it('subscribes while active and unsubscribes on cleanup', () => { + usePresenceSubscription('presence:instance:sandbox-1', true); + + expect(testState.subscribe).toHaveBeenCalledWith(['presence:instance:sandbox-1']); + + testState.cleanups[0]?.(); + + expect(testState.unsubscribe).toHaveBeenCalledWith(['presence:instance:sandbox-1']); + }); + + it('does not subscribe without an active context', () => { + usePresenceSubscription('presence:instance:sandbox-1', false); + usePresenceSubscription(null, true); + + expect(testState.subscribe).not.toHaveBeenCalled(); + expect(testState.unsubscribe).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts b/packages/kilo-chat-hooks/src/use-presence-subscription.ts similarity index 72% rename from apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts rename to packages/kilo-chat-hooks/src/use-presence-subscription.ts index b3c860bb89..b3fea58805 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts +++ b/packages/kilo-chat-hooks/src/use-presence-subscription.ts @@ -1,13 +1,11 @@ import { useEffect } from 'react'; -import { useEventServiceClient } from './use-kilo-chat-client'; +import { useEventServiceClient } from './context'; export function usePresenceSubscription(context: string | null, active: boolean) { const eventService = useEventServiceClient(); useEffect(() => { - if (!active || !context) { - return undefined; - } + if (!active || context === null) return; eventService.subscribe([context]); return () => { eventService.unsubscribe([context]); From 95c1842ff504e358a182f01961a72cd02b32a46c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 14:00:08 +0200 Subject: [PATCH 228/289] refactor(kilo-chat): share message action policy --- .../kilo-chat/conversation-screen.tsx | 20 +++--- .../kilo-chat/components/MessageBubble.tsx | 8 ++- .../components/message-bubble-actions.test.ts | 4 +- packages/kilo-chat/src/index.ts | 4 ++ .../src}/message-action-availability.ts | 2 +- .../test/message-action-availability.test.ts | 63 +++++++++++++++++++ 6 files changed, 86 insertions(+), 15 deletions(-) rename {apps/web/src/app/(app)/claw/kilo-chat/components => packages/kilo-chat/src}/message-action-availability.ts (92%) create mode 100644 packages/kilo-chat/test/message-action-availability.test.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index e7495fcc99..f8ac7f86eb 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -16,7 +16,12 @@ import { useExecuteAction, useRemoveReaction, } from '@kilocode/kilo-chat-hooks'; -import { type ExecApprovalDecision, formatKiloChatError, type Message } from '@kilocode/kilo-chat'; +import { + buildMessageActionAvailability, + type ExecApprovalDecision, + formatKiloChatError, + type Message, +} from '@kilocode/kilo-chat'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; @@ -225,16 +230,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl if (message.deleted) { return; } - const isPendingMessage = message.id.startsWith('pending-'); const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; - const canUseApiBackedActions = !isPendingMessage; - const canMutateFailedMessage = !message.deliveryFailed; + const actionAvailability = buildMessageActionAvailability(message, isOwnMessage); const actionSheet = buildMessageActionSheetOptions({ - canReact: currentUserId !== null && canMutateFailedMessage, - canReply: canMutateFailedMessage, - canEdit: canUseApiBackedActions && isOwnMessage && canMutateFailedMessage, - canDelete: canUseApiBackedActions && isOwnMessage, - isPendingMessage, + canReact: currentUserId !== null && actionAvailability.canReact, + canReply: actionAvailability.canReply, + canEdit: actionAvailability.canEdit, + canDelete: actionAvailability.canDelete, }); showActionSheetWithOptions( { diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx index 09e3d00f58..0683c56943 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx @@ -13,10 +13,14 @@ import type { ExecApprovalDecision, ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; -import { MESSAGE_TEXT_MAX_CHARS, ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat'; +import { + buildMessageActionAvailability, + MESSAGE_TEXT_MAX_CHARS, + ulidToTimestamp, + contentBlocksToText, +} from '@kilocode/kilo-chat'; import { useKiloChatContext } from './kiloChatContext'; import { toast } from 'sonner'; -import { buildMessageActionAvailability } from './message-action-availability'; import { isMessageEditOverLimit, submitMessageEdit } from './message-edit-state'; const EDIT_COUNTER_SHOW_AT = Math.floor(MESSAGE_TEXT_MAX_CHARS * 0.8); diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts index 0c06d322f6..1c6f982358 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/message-bubble-actions.test.ts @@ -1,6 +1,4 @@ -import { type Message } from '@kilocode/kilo-chat'; - -import { buildMessageActionAvailability } from './message-action-availability'; +import { buildMessageActionAvailability, type Message } from '@kilocode/kilo-chat'; const baseMessage = { id: '01K8ZB8B3H9BRWZ6KCN39AX09G', diff --git a/packages/kilo-chat/src/index.ts b/packages/kilo-chat/src/index.ts index 56ec2e3b4a..5db1d7d624 100644 --- a/packages/kilo-chat/src/index.ts +++ b/packages/kilo-chat/src/index.ts @@ -8,6 +8,10 @@ export { decodeConversationCursor, type ConversationCursor, } from './utils'; +export { + buildMessageActionAvailability, + type MessageActionAvailability, +} from './message-action-availability'; export type * from './types'; export type { KiloChatEvent, KiloChatEventName, KiloChatEventOf } from './events'; export * from './schemas'; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/message-action-availability.ts b/packages/kilo-chat/src/message-action-availability.ts similarity index 92% rename from apps/web/src/app/(app)/claw/kilo-chat/components/message-action-availability.ts rename to packages/kilo-chat/src/message-action-availability.ts index 3bba19ccc5..51eafb4102 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/message-action-availability.ts +++ b/packages/kilo-chat/src/message-action-availability.ts @@ -1,4 +1,4 @@ -import { type Message } from '@kilocode/kilo-chat'; +import { type Message } from './types'; export type MessageActionAvailability = { canReact: boolean; diff --git a/packages/kilo-chat/test/message-action-availability.test.ts b/packages/kilo-chat/test/message-action-availability.test.ts new file mode 100644 index 0000000000..3f566a83ab --- /dev/null +++ b/packages/kilo-chat/test/message-action-availability.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { type Message } from '../src'; +import { buildMessageActionAvailability } from '../src/message-action-availability'; + +const baseMessage = { + id: '01K8ZB8B3H9BRWZ6KCN39AX09G', + senderId: 'user-1', + content: [{ type: 'text', text: 'hello' }], + inReplyToMessageId: null, + replyTo: null, + updatedAt: null, + clientUpdatedAt: null, + deleted: false, + deliveryFailed: false, + reactions: [], +} satisfies Message; + +describe('buildMessageActionAvailability', () => { + it('allows API-backed actions for persisted own messages', () => { + expect(buildMessageActionAvailability(baseMessage, true)).toEqual({ + canReact: true, + canEdit: true, + canDelete: true, + canReply: true, + canExecuteAction: true, + }); + }); + + it('blocks owner-only actions for other users messages', () => { + expect(buildMessageActionAvailability(baseMessage, false)).toEqual({ + canReact: true, + canEdit: false, + canDelete: false, + canReply: true, + canExecuteAction: true, + }); + }); + + it('blocks API-backed actions for pending messages', () => { + const pendingMessage = { ...baseMessage, id: 'pending-client-1' } satisfies Message; + + expect(buildMessageActionAvailability(pendingMessage, true)).toEqual({ + canReact: false, + canEdit: false, + canDelete: false, + canReply: false, + canExecuteAction: false, + }); + }); + + it('allows deleting but not mutating delivery-failed own messages', () => { + const failedMessage = { ...baseMessage, deliveryFailed: true } satisfies Message; + + expect(buildMessageActionAvailability(failedMessage, true)).toEqual({ + canReact: false, + canEdit: false, + canDelete: true, + canReply: false, + canExecuteAction: true, + }); + }); +}); From 4727e549a8c3c6d581a285142a888e5d87995a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 14:00:20 +0200 Subject: [PATCH 229/289] test(notifications): mint badge route tokens for worker env --- services/notifications/src/__tests__/routes-badges.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/notifications/src/__tests__/routes-badges.test.ts b/services/notifications/src/__tests__/routes-badges.test.ts index ffc76ca28f..0838988531 100644 --- a/services/notifications/src/__tests__/routes-badges.test.ts +++ b/services/notifications/src/__tests__/routes-badges.test.ts @@ -27,7 +27,7 @@ async function tokenFor(userId: string): Promise { pepper: null, secret: TEST_JWT_SECRET, expiresInSeconds: 3600, - env: 'production', + env: env.WORKER_ENV, extra: { tokenSource: 'kilo-chat' }, }); return token; From 1e192e9dfef7a24332860de6acc064895cd28eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 14:19:33 +0200 Subject: [PATCH 230/289] refactor(worker-utils): remove unused chat token verifier --- packages/worker-utils/src/index.ts | 3 +-- packages/worker-utils/src/kilo-token.ts | 26 ------------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 2b2bf72524..35e7bc7893 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -45,11 +45,10 @@ export { CloudAgentNextBillingError, CloudAgentNextError } from './cloud-agent-n export { signKiloToken, verifyKiloToken, - verifyKiloChatToken, kiloTokenPayload, KILO_TOKEN_VERSION, } from './kilo-token.js'; -export type { KiloChatTokenPayload, KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.js'; +export type { KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.js'; export { SessionMetricsParamsSchema, TerminationReasons } from './session-metrics-schema.js'; export type { SessionMetricsParams, SessionMetricsParamsInput } from './session-metrics-schema.js'; diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index 5286364101..c4264bd372 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -37,11 +37,6 @@ export const kiloTokenPayload = z.object({ export type KiloTokenPayload = z.infer; const signKiloTokenPayload = kiloTokenPayload.omit({ iat: true, exp: true }).strict(); -export type KiloChatTokenPayload = { - userId: string; - pepper: string | null; -}; - /** * Optional claims beyond the core fields (userId, pepper, version, env). * Derived from KiloTokenPayload so sign and verify stay in sync. @@ -108,24 +103,3 @@ export async function verifyKiloToken(token: string, secret: string): Promise { - const payload = await verifyKiloToken(token, secret); - - if (payload.tokenSource !== 'kilo-chat') { - throw new Error('Invalid token source'); - } - - if (payload.env !== expectedEnv) { - throw new Error('Invalid token environment'); - } - - return { - userId: payload.kiloUserId, - pepper: payload.apiTokenPepper ?? null, - }; -} From 4dc41ce09b0805c223971c12ea849eb8b8123e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 3 May 2026 19:34:49 +0200 Subject: [PATCH 231/289] fix(kilo-chat): prevent trial banner page scroll --- apps/web/src/app/(app)/claw/chat/layout.tsx | 7 ++++++- .../app/(app)/claw/components/billing/BillingBanner.tsx | 2 +- .../app/(app)/claw/components/billing/BillingWrapper.tsx | 2 +- .../app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx | 5 ++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(app)/claw/chat/layout.tsx b/apps/web/src/app/(app)/claw/chat/layout.tsx index fbe24ee477..93ed3d139d 100644 --- a/apps/web/src/app/(app)/claw/chat/layout.tsx +++ b/apps/web/src/app/(app)/claw/chat/layout.tsx @@ -23,10 +23,15 @@ export default function ChatRootLayout({ children }: { children: React.ReactNode instanceErrorMessage={instanceErrorMessage} onRetryInstanceStatus={() => void refetch()} assistantName={status?.botName ?? null} + className="flex-1" > {children} ); - return {content}; + return ( +
+ {content} +
+ ); } diff --git a/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx b/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx index 8817d8dfbc..9e7c617398 100644 --- a/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/BillingBanner.tsx @@ -196,7 +196,7 @@ export function BillingBanner({ return (
void; }) { return ( -
+
🦀
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index bd39ff8489..e41f48714a 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -10,6 +10,7 @@ import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; import { kiloclawInstanceContext } from '@kilocode/event-service'; import { useEventServiceClient } from '@/contexts/EventServiceContext'; +import { cn } from '@/lib/utils'; import { useConversations, useCreateConversation, @@ -31,6 +32,7 @@ type KiloChatLayoutProps = { onRetryInstanceStatus: () => void; instanceStatus: string | null; assistantName: string | null; + className?: string; children: React.ReactNode; }; @@ -45,6 +47,7 @@ export function KiloChatLayout({ onRetryInstanceStatus, instanceStatus, assistantName, + className, children, }: KiloChatLayoutProps) { const router = useRouter(); @@ -178,7 +181,7 @@ export function KiloChatLayout({ return ( -
+
{/* Conversation sidebar */}
Date: Mon, 4 May 2026 13:18:59 +0200 Subject: [PATCH 232/289] fix(kilo-chat): refresh inactive conversation messages --- .../kilo-chat/components/KiloChatLayout.tsx | 11 +- .../src/use-conversations.test.ts | 113 +++++++++++++++++- .../kilo-chat-hooks/src/use-conversations.ts | 6 + 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index e41f48714a..f62c314ece 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -71,6 +71,7 @@ export function KiloChatLayout({ // Unknown conversations still invalidate so they can be fetched into the list. useEffect(() => { return registerConversationListCacheHandlers({ + activeConversationId: params?.conversationId ?? null, currentUserId, eventService, kiloChatClient, @@ -78,7 +79,15 @@ export function KiloChatLayout({ queryKey: conversationsQueryKey, sandboxId, }); - }, [currentUserId, eventService, kiloChatClient, queryClient, conversationsQueryKey, sandboxId]); + }, [ + currentUserId, + eventService, + kiloChatClient, + params?.conversationId, + queryClient, + conversationsQueryKey, + sandboxId, + ]); const createConversation = useCreateConversation(kiloChatClient); const renameConversation = useRenameConversation(kiloChatClient); diff --git a/packages/kilo-chat-hooks/src/use-conversations.test.ts b/packages/kilo-chat-hooks/src/use-conversations.test.ts index 30da5dcd6f..771addee36 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.test.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.test.ts @@ -1,9 +1,14 @@ -import type { ConversationDetail, ConversationListItem } from '@kilocode/kilo-chat'; +import type { + ConversationActivityEvent, + ConversationDetail, + ConversationListItem, +} from '@kilocode/kilo-chat'; import { ulidToTimestamp } from '@kilocode/kilo-chat'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; import { QueryClient } from '@tanstack/react-query'; import { describe, expect, it } from 'vitest'; -import { conversationKey, conversationsKey } from './query-keys'; +import { conversationKey, conversationsKey, messagesKey } from './query-keys'; import { applyConversationActivityToPages, applyConversationCreatedToPages, @@ -16,6 +21,7 @@ import { settleMarkConversationRead, settleCreateConversation, settleRenameConversation, + registerConversationListCacheHandlers, type ConversationListInfiniteData, shouldApplyConversationRead, } from './use-conversations'; @@ -410,6 +416,109 @@ describe('settleLeaveConversation', () => { }); }); +describe('registerConversationListCacheHandlers', () => { + type HandlerOptions = Parameters[0]; + type EventHandler = (ctx: string, event: T) => void; + + it('invalidates a conversation message cache when instance activity arrives', () => { + const queryClient = new QueryClient(); + const sandboxId = 'sandbox-a'; + const conversationId = '01KQK8A1111111111111111111'; + const listKey = conversationsKey(sandboxId); + const messageKey = messagesKey(conversationId); + let activityHandler: EventHandler | undefined; + const off = () => {}; + + const kiloChatClient: HandlerOptions['kiloChatClient'] = { + onConversationCreated: () => off, + onConversationRenamed: () => off, + onConversationLeft: () => off, + onConversationRead: () => off, + onConversationActivity: handler => { + activityHandler = handler; + return off; + }, + }; + const eventService: HandlerOptions['eventService'] = { + onReconnect: () => off, + }; + + queryClient.setQueryData( + listKey, + conversationsData([[conversation(conversationId, { lastActivityAt: 100 })]], [null]) + ); + queryClient.setQueryData(messageKey, { stale: 'old-first-page' }); + + registerConversationListCacheHandlers({ + currentUserId: 'user-current', + eventService, + kiloChatClient, + queryClient, + queryKey: listKey, + sandboxId, + }); + + expect(queryClient.getQueryState(messageKey)?.isInvalidated).toBe(false); + if (!activityHandler) throw new Error('activity handler was not registered'); + + activityHandler(kiloclawInstanceContext(sandboxId), { + conversationId, + lastActivityAt: 200, + }); + + expect(queryClient.getQueryState(messageKey)?.isInvalidated).toBe(true); + }); + + it('leaves active conversation messages to the mounted message handler', () => { + const queryClient = new QueryClient(); + const sandboxId = 'sandbox-a'; + const conversationId = '01KQK8A2222222222222222222'; + const listKey = conversationsKey(sandboxId); + const messageKey = messagesKey(conversationId); + let activityHandler: EventHandler | undefined; + const off = () => {}; + + const kiloChatClient: HandlerOptions['kiloChatClient'] = { + onConversationCreated: () => off, + onConversationRenamed: () => off, + onConversationLeft: () => off, + onConversationRead: () => off, + onConversationActivity: handler => { + activityHandler = handler; + return off; + }, + }; + const eventService: HandlerOptions['eventService'] = { + onReconnect: () => off, + }; + + queryClient.setQueryData( + listKey, + conversationsData([[conversation(conversationId, { lastActivityAt: 100 })]], [null]) + ); + queryClient.setQueryData(messageKey, { current: 'active-first-page' }); + + registerConversationListCacheHandlers({ + activeConversationId: conversationId, + currentUserId: 'user-current', + eventService, + kiloChatClient, + queryClient, + queryKey: listKey, + sandboxId, + }); + + if (!activityHandler) throw new Error('activity handler was not registered'); + + activityHandler(kiloclawInstanceContext(sandboxId), { + conversationId, + lastActivityAt: 200, + }); + + expect(queryClient.getQueryState(messageKey)?.isInvalidated).toBe(false); + }); +}); + describe('optimistic leave conversation rollback', () => { it('restores only the removed row while preserving newer sidebar patches', () => { const queryClient = new QueryClient(); diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 0264c5ecea..92ca2874c5 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -51,6 +51,7 @@ type ReconnectEventService = { }; type RegisterConversationListCacheHandlersOptions = { + activeConversationId?: string | null; currentUserId: string | null; eventService: ReconnectEventService; kiloChatClient: KiloChatConversationEventClient; @@ -495,6 +496,7 @@ function invalidateConversationListQuery(queryClient: QueryClient, queryKey: Que } export function registerConversationListCacheHandlers({ + activeConversationId = null, currentUserId, eventService, kiloChatClient, @@ -521,6 +523,10 @@ export function registerConversationListCacheHandlers({ } function patchActivity(event: ConversationActivityEvent): void { + if (event.conversationId !== activeConversationId) { + void queryClient.invalidateQueries({ queryKey: messagesKey(event.conversationId) }); + } + const result = applyConversationActivityToPages( queryClient.getQueryData(queryKey), event From 596355735ab5689d966aff60df68751eeef3479b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 13:40:04 +0200 Subject: [PATCH 233/289] fix(kiloclaw): clarify kilo chat message tool contract --- .../plugins/kilo-chat/src/channel.test.ts | 40 ++++++++- .../kiloclaw/plugins/kilo-chat/src/channel.ts | 82 +++++++++++++++++-- .../plugins/kilo-chat/src/client.test.ts | 14 +++- .../kiloclaw/plugins/kilo-chat/src/client.ts | 3 +- 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts b/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts index 5fa7926da2..55edcadf7a 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/channel.test.ts @@ -151,17 +151,53 @@ describe('kilo-chat actions adapter', () => { expect(discovery?.actions).toContain('channel-create'); }); - it('describeMessageTool does not expose additionalMembers until approval authorization supports groups', () => { + it('describeMessageTool advertises Kilo Chat action parameters', () => { const adapter = kiloChatPlugin.actions; const discovery = adapter!.describeMessageTool?.({ cfg: {} as never, accountId: null }); expect(discovery?.schema).toBeDefined(); const schema = Array.isArray(discovery?.schema) ? discovery.schema[0] : discovery?.schema; expect(schema?.properties).not.toHaveProperty('additionalMembers'); + expect(schema?.properties).not.toHaveProperty('target'); + expect(schema?.properties).toHaveProperty('conversationId'); expect(schema?.properties).toHaveProperty('groupId'); - expect(schema?.properties).toHaveProperty('target'); + expect(schema?.properties).toHaveProperty('messageId'); + expect(schema?.properties).toHaveProperty('message'); + expect(schema?.properties).toHaveProperty('emoji'); + expect(schema?.properties).toHaveProperty('remove'); + expect(schema?.properties).toHaveProperty('name'); + expect(schema?.properties).toHaveProperty('limit'); + expect(schema?.properties).toHaveProperty('before'); + expect(schema?.properties).toHaveProperty('memberId'); + expect(schema?.properties).toHaveProperty('userId'); + expect(schema?.properties?.memberId.description).toContain('member-info'); + expect(schema?.properties?.userId.description).toContain('memberId'); + expect(schema?.properties?.conversationId.description).toContain('compatibility alias'); expect(schema?.visibility).toBe('current-channel'); }); + it('registers Kilo Chat conversation aliases for destination-bearing actions', () => { + const aliases = kiloChatPlugin.actions?.messageActionTargetAliases; + const expected = ['conversationId', 'groupId']; + expect(aliases?.send?.aliases).toEqual(expected); + expect(aliases?.read?.aliases).toEqual(expected); + expect(aliases?.react?.aliases).toEqual(expected); + expect(aliases?.edit?.aliases).toEqual(expected); + expect(aliases?.delete?.aliases).toEqual(expected); + expect(aliases?.renameGroup?.aliases).toEqual(expected); + }); + + it('adds concise Kilo Chat message tool hints', () => { + const hints = kiloChatPlugin.agentPrompt?.messageToolHints?.({ + cfg: {} as never, + accountId: null, + }); + expect(hints).toContain( + '- `member-info`: use `memberId` or `userId` to inspect one member; omit both to list members. Do not use `target` for the member id.' + ); + expect(hints).toContain('- `renameGroup`: pass `conversationId` or `groupId` plus `name`.'); + expect(hints?.join('\n')).toContain('conversationId'); + }); + it('supportsAction returns true for standard actions and false for unsupported ones', () => { const adapter = kiloChatPlugin.actions; expect(adapter?.supportsAction?.({ action: 'react' as never })).toBe(true); diff --git a/services/kiloclaw/plugins/kilo-chat/src/channel.ts b/services/kiloclaw/plugins/kilo-chat/src/channel.ts index b135dcf42f..76f0f51ec2 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/channel.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/channel.ts @@ -23,6 +23,7 @@ import { stripPrefix } from './action-schemas'; const CHANNEL_ID = 'kilo-chat'; export const DEFAULT_ACCOUNT_ID = 'default'; const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i; +const CONVERSATION_TARGET_ALIASES = ['conversationId', 'groupId']; function isValidUlid(raw: string): boolean { return ULID_RE.test(raw); @@ -78,6 +79,18 @@ const pluginBase = createChannelPluginBase({ setup: { applyAccountConfig: ({ cfg }) => cfg, }, + agentPrompt: { + messageToolHints: () => [ + '- Kilo Chat uses the shared `message` tool. Prefer `target` for explicit conversation destinations; omit it to act in the current conversation when supported.', + '- `send`: pass `message` plus `target`; `conversationId` and `groupId` are accepted compatibility aliases for the target conversation.', + '- Kilo Chat actions: `channel-list` lists conversations with optional `limit`; `channel-create` creates a conversation with optional `name`.', + '- `read`: omit `target` for the current conversation, or pass `target`/`conversationId`; use `limit` and `before` for pagination.', + '- `react`: pass `messageId` and the actual emoji in `emoji`; set `remove=true` to remove that emoji. If `messageId` is omitted, the current inbound message is used when available.', + '- `edit` and `delete`: pass `messageId`; `edit` also requires replacement `message` text.', + '- `member-info`: use `memberId` or `userId` to inspect one member; omit both to list members. Do not use `target` for the member id.', + '- `renameGroup`: pass `conversationId` or `groupId` plus `name`.', + ], + }, config: { listAccountIds: () => ['default'], resolveAccount, @@ -173,15 +186,65 @@ export const kiloChatPlugin = createChatChannelPlugin({ ] as const, schema: { properties: { + conversationId: Type.Optional( + Type.String({ + description: + 'Kilo Chat conversation id. Prefer `target` for OpenClaw-native sends, but this is accepted as a compatibility alias for `send`, `read`, `react`, `edit`, `delete`, and `renameGroup` when not acting on the current conversation.', + }) + ), groupId: Type.Optional( Type.String({ description: - 'Conversation/group id. Required for `renameGroup` (must be the target conversation, not the current one). Optional elsewhere — falls back to the current conversation.', + 'Alias for `conversationId`. Accepted for `send`, `read`, `react`, `edit`, `delete`, and `renameGroup`; required for `renameGroup` if `conversationId` is omitted.', + }) + ), + messageId: Type.Optional( + Type.String({ + description: + 'Target Kilo Chat message id for `react`, `edit`, and `delete`. Defaults to the current inbound message when available.', + }) + ), + message: Type.Optional( + Type.String({ + description: 'Message body for `send` and replacement text for `edit`.', + }) + ), + emoji: Type.Optional( + Type.String({ + description: 'Actual emoji for `react`, for example 👍.', + }) + ), + remove: Type.Optional( + Type.Boolean({ + description: 'For `react`, remove the given emoji reaction instead of adding it.', + }) + ), + name: Type.Optional( + Type.String({ + description: 'Conversation title for `channel-create` or `renameGroup`.', + }) + ), + limit: Type.Optional( + Type.Number({ + description: + 'Maximum conversations or messages to return for `channel-list` or `read`.', + }) + ), + before: Type.Optional( + Type.String({ + description: + 'Pagination cursor for `read`; use the `nextCursor` returned by a previous read.', + }) + ), + memberId: Type.Optional( + Type.String({ + description: + 'Member/user id to inspect with `member-info`. Omit to list all members.', }) ), - target: Type.Optional( + userId: Type.Optional( Type.String({ - description: 'Member id to inspect with `member-info`. Omit to list all members.', + description: 'Alias for `memberId` for `member-info`.', }) ), }, @@ -189,12 +252,15 @@ export const kiloChatPlugin = createChatChannelPlugin({ }, }), // Tell the OpenClaw message-tool runtime that `groupId`/`conversationId` - // count as a target for `renameGroup`. Without this, the runtime treats - // the action as targetless and injects `toolContext.currentChannelId` - // as `to`, which would silently rename the active conversation instead - // of the one the caller specified. + // count as destination fields so explicit Kilo Chat conversations are not + // overwritten by the current conversation during tool normalization. messageActionTargetAliases: { - renameGroup: { aliases: ['groupId', 'conversationId'] }, + send: { aliases: CONVERSATION_TARGET_ALIASES }, + read: { aliases: CONVERSATION_TARGET_ALIASES }, + react: { aliases: CONVERSATION_TARGET_ALIASES }, + edit: { aliases: CONVERSATION_TARGET_ALIASES }, + delete: { aliases: CONVERSATION_TARGET_ALIASES }, + renameGroup: { aliases: CONVERSATION_TARGET_ALIASES }, }, supportsAction: ({ action }: { action: string }) => action === 'react' || diff --git a/services/kiloclaw/plugins/kilo-chat/src/client.test.ts b/services/kiloclaw/plugins/kilo-chat/src/client.test.ts index 5014a17123..277dc6063c 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/client.test.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/client.test.ts @@ -616,12 +616,22 @@ describe('listConversations', () => { describe('createConversation', () => { const TEST_ULID = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + const createConversationResponse = { + conversationId: TEST_ULID, + conversation: { + conversationId: TEST_ULID, + title: 'My Chat', + lastActivityAt: null, + lastReadAt: null, + joinedAt: 123, + }, + }; it('POSTs to /_kilo/kilo-chat/conversations and returns conversationId', async () => { const calls: Array<{ url: string; init: RequestInit }> = []; const fetchImpl = (async (url: string | URL, init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }); - return new Response(JSON.stringify({ conversationId: TEST_ULID }), { + return new Response(JSON.stringify(createConversationResponse), { status: 201, headers: { 'content-type': 'application/json' }, }); @@ -645,7 +655,7 @@ describe('createConversation', () => { const calls: Array<{ url: string; init: RequestInit }> = []; const fetchImpl = (async (url: string | URL, init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }); - return new Response(JSON.stringify({ conversationId: TEST_ULID }), { status: 201 }); + return new Response(JSON.stringify(createConversationResponse), { status: 201 }); }) as typeof fetch; const client = createKiloChatClient({ diff --git a/services/kiloclaw/plugins/kilo-chat/src/client.ts b/services/kiloclaw/plugins/kilo-chat/src/client.ts index 81d20a04e2..90c061f9e8 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/client.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/client.ts @@ -388,12 +388,13 @@ export function createKiloChatClient(options: KiloChatClientOptions): KiloChatCl `kilo-chat: controller POST conversations responded ${response.status}: ${await response.text()}` ); } - return parseOrThrow( + const parsed = parseOrThrow( createConversationResponseSchema, await response.json(), 'createConversation', { conversationId: 'conversationId' } ); + return { conversationId: parsed.conversationId }; } async function sendBotStatus(params: BotStatusParams): Promise { From 619a7edb763665284f2ffa8f9d5cce14165c4da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 14:01:51 +0200 Subject: [PATCH 234/289] chore(mobile): add local env example --- apps/mobile/.env.local.example | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/mobile/.env.local.example diff --git a/apps/mobile/.env.local.example b/apps/mobile/.env.local.example new file mode 100644 index 0000000000..cec091a93c --- /dev/null +++ b/apps/mobile/.env.local.example @@ -0,0 +1,10 @@ +# Client-side env vars are bundled into the app binary and are not secret. +API_BASE_URL=http://localhost:3000 +WEB_BASE_URL=http://localhost:3000 +CLOUD_AGENT_WS_URL=ws://localhost:8794 +SESSION_INGEST_WS_URL=ws://localhost:8800 +APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 +APPSFLYER_APP_ID=6761193135 +KILO_CHAT_URL=http://localhost:8808 +EVENT_SERVICE_URL=ws://localhost:8809 +NOTIFICATIONS_URL=http://localhost:8804 From abce63e7c54efb097447fd3e3779c469e6074dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 14:05:10 +0200 Subject: [PATCH 235/289] docs(mobile): document local env host access --- apps/mobile/.env.local.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile/.env.local.example b/apps/mobile/.env.local.example index cec091a93c..2e7552a933 100644 --- a/apps/mobile/.env.local.example +++ b/apps/mobile/.env.local.example @@ -1,4 +1,9 @@ # Client-side env vars are bundled into the app binary and are not secret. +# +# localhost works for local tooling and most simulator flows. When running on a +# physical phone, replace localhost with your development machine's LAN IP. +# On macOS, get the active LAN IP with: +# route -n get default | awk '/interface:/{print $2}' | xargs ipconfig getifaddr API_BASE_URL=http://localhost:3000 WEB_BASE_URL=http://localhost:3000 CLOUD_AGENT_WS_URL=ws://localhost:8794 From 9e245508410f9639852d6580d5f72d4f6315bea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 14:53:18 +0200 Subject: [PATCH 236/289] fix(dev): respect configured app url --- scripts/dev.sh | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 7e1b049b02..0dbcc5c610 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -42,6 +42,34 @@ for envfile in .env.local .env.development.local; do fi done +read_env_value() { + local key="$1" + local file + local value + + for file in .env.development.local .env.local "$REPO_ROOT/.env.development.local" "$REPO_ROOT/.env.local"; do + if [ -f "$file" ]; then + value=$(awk -F= -v key="$key" ' + $1 == key { + value = substr($0, length(key) + 2) + gsub(/^["'\'']|["'\'']$/, "", value) + print value + exit + } + ' "$file") + if [ -n "$value" ]; then + echo "$value" + return + fi + fi + done +} + export PORT +APP_URL_OVERRIDE="${APP_URL_OVERRIDE:-$(read_env_value APP_URL_OVERRIDE)}" +NEXTAUTH_URL="${NEXTAUTH_URL:-$(read_env_value NEXTAUTH_URL)}" +NEXT_DEV_HOSTNAME="${NEXT_DEV_HOSTNAME:-0.0.0.0}" +export APP_URL_OVERRIDE +export NEXT_DEV_HOSTNAME export NEXTAUTH_URL="${NEXTAUTH_URL:-${APP_URL_OVERRIDE:-http://localhost:$PORT}}" -exec next dev -p "$PORT" "$@" +exec next dev -H "$NEXT_DEV_HOSTNAME" -p "$PORT" "$@" From 8e68906c04845fd52fe3d5b776b9750acefd0bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 14:53:40 +0200 Subject: [PATCH 237/289] fix(mobile): center bottom tab labels --- apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 39 +++++++++++--------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index d6b209ab43..b16ae4347e 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -2,16 +2,30 @@ import * as Haptics from 'expo-haptics'; import { type Href, Tabs, useRouter } from 'expo-router'; import { Bot, House, MessageSquare } from 'lucide-react-native'; import { useEffect, useState } from 'react'; -import { Platform, View } from 'react-native'; +import { Platform, type TextStyle, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurBar } from '@/components/ui/blur-bar'; -import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; const ANDROID_TAB_BAR_EXTRA_PADDING = 4; +const TAB_BAR_ITEM_CONTENT_WIDTH = 64; +const TAB_BAR_ICON_STYLE = { + alignItems: 'center', + justifyContent: 'center', + width: TAB_BAR_ITEM_CONTENT_WIDTH, +} satisfies ViewStyle; +const TAB_BAR_LABEL_STYLE = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 10, + letterSpacing: 0, + marginTop: 2, + minWidth: TAB_BAR_ITEM_CONTENT_WIDTH, + textAlign: 'center', + textTransform: 'uppercase', +} satisfies TextStyle; export const unstable_settings = { initialRouteName: '(0_home)', @@ -25,18 +39,6 @@ function TabBarBackground() { ); } -function renderTabBarLabel(label: string) { - // Mirrors the "eyebrow" variant (mono/uppercase/10px/muted) without the - // letter-spacing, which would otherwise push the visible glyphs off-center - // beneath the icon since iOS and Android disagree on whether trailing - // letter-spacing is included in the measured text width. - return ( - - {label} - - ); -} - export default function TabsLayout() { const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); @@ -62,6 +64,9 @@ export default function TabsLayout() { tabBarActiveTintColor: colors.foreground, tabBarInactiveTintColor: colors.mutedForeground, tabBarBackground: TabBarBackground, + tabBarIconStyle: TAB_BAR_ICON_STYLE, + tabBarLabelPosition: 'below-icon', + tabBarLabelStyle: TAB_BAR_LABEL_STYLE, tabBarStyle: { backgroundColor: 'transparent', borderTopColor: 'transparent', @@ -78,10 +83,10 @@ export default function TabsLayout() { name="(0_home)" options={{ title: 'Home', + tabBarLabel: 'Home', tabBarIcon: ({ color, focused }) => ( ), - tabBarLabel: () => renderTabBarLabel('Home'), }} listeners={{ tabPress: () => { @@ -93,10 +98,10 @@ export default function TabsLayout() { name="(1_kiloclaw)" options={{ title: 'KiloClaw', + tabBarLabel: 'KiloClaw', tabBarIcon: ({ color, focused }) => ( ), - tabBarLabel: () => renderTabBarLabel('KiloClaw'), }} listeners={{ tabPress: e => { @@ -124,10 +129,10 @@ export default function TabsLayout() { name="(2_agents)" options={{ title: 'Agents', + tabBarLabel: 'Agents', tabBarIcon: ({ color, focused }) => ( ), - tabBarLabel: () => renderTabBarLabel('Agents'), }} listeners={{ tabPress: () => { From 246fa95a41482d936adcb552e90b4b3ee5732458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:23:11 +0200 Subject: [PATCH 238/289] fix: fix kilo-chat-hooks dependency resolution --- apps/mobile/package.json | 5 +++++ pnpm-lock.yaml | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 37731c5477..b84007a8a4 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -95,5 +95,10 @@ "typescript": "catalog:", "vitest": "^4.1.0" }, + "dependenciesMeta": { + "@kilocode/kilo-chat-hooks": { + "injected": true + } + }, "private": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7cd928d5f..8dde6338e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: version: link:../../packages/kilo-chat '@kilocode/kilo-chat-hooks': specifier: workspace:* - version: link:../../packages/kilo-chat-hooks + version: file:packages/kilo-chat-hooks(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0) '@kilocode/notifications': specifier: workspace:* version: link:../../packages/notifications @@ -323,6 +323,9 @@ importers: ulid: specifier: 3.0.1 version: 3.0.1 + dependenciesMeta: + '@kilocode/kilo-chat-hooks': + injected: true devDependencies: '@sentry/cli': specifier: ^3.3.4 @@ -4531,6 +4534,12 @@ packages: react: '*' react-native: '*' + '@kilocode/kilo-chat-hooks@file:packages/kilo-chat-hooks': + resolution: {directory: packages/kilo-chat-hooks, type: directory} + peerDependencies: + '@tanstack/react-query': '*' + react: '*' + '@kilocode/plugin@7.2.14': resolution: {integrity: sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==} peerDependencies: @@ -18525,6 +18534,13 @@ snapshots: react: 19.2.0 react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + '@kilocode/kilo-chat-hooks@file:packages/kilo-chat-hooks(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0)': + dependencies: + '@kilocode/event-service': link:packages/event-service + '@kilocode/kilo-chat': link:packages/kilo-chat + '@tanstack/react-query': 5.90.21(react@19.2.0) + react: 19.2.0 + '@kilocode/plugin@7.2.14': dependencies: '@kilocode/sdk': 7.2.14 From 58d93ecd5300e5e96b16a75feef6db9ac52a1fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:34:37 +0200 Subject: [PATCH 239/289] fix(mobile): use expo crypto for chat client ids --- .../kilo-chat/message-presentation.test.ts | 23 ++++++++++++++++++- .../kilo-chat/message-presentation.ts | 12 +++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index 588b47e122..f9a4b0b309 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createMessageRequestSchema, type Message } from '@kilocode/kilo-chat'; import { @@ -10,6 +10,23 @@ import { getReplyPreviewText, } from './message-presentation'; +vi.mock('expo-crypto', () => ({ + getRandomValues: (typedArray: Uint8Array) => { + typedArray[0] = 128; + return typedArray; + }, +})); + +vi.mock('ulid', () => ({ + ulid: (_seedTime?: number, prng?: () => number) => { + if (!prng) { + throw new Error('missing explicit PRNG'); + } + prng(); + return '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + }, +})); + function message(overrides: Partial = {}): Message { return { id: 'message-1', @@ -27,6 +44,10 @@ function message(overrides: Partial = {}): Message { } describe('buildSendMessageVariables', () => { + it('creates client ids without relying on ULID PRNG auto-detection', () => { + expect(createSendMessageClientId()).toBe('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + }); + it('builds variables accepted by the create message request schema', () => { const variables = buildSendMessageVariables({ conversationId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index 311afa122c..764100019c 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -3,6 +3,7 @@ import { type Message, type ReplyToMessageSnapshot, } from '@kilocode/kilo-chat'; +import * as Crypto from 'expo-crypto'; import { ulid } from 'ulid'; type SendMessageVariables = CreateMessageRequest & { clientId: string }; @@ -31,7 +32,16 @@ export function buildSendMessageVariables({ } export function createSendMessageClientId(): string { - return ulid(); + return ulid(undefined, expoCryptoPrng); +} + +function expoCryptoPrng(): number { + const bytes = Crypto.getRandomValues(new Uint8Array(1)); + const byte = bytes[0]; + if (byte === undefined) { + throw new Error('Failed to generate a random byte'); + } + return byte / 255; } function contentBlocksToPreviewText(content: Message['content']): string { From 21b8ba3de58f21f78a29f3125317bcbf30d29f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:39:27 +0200 Subject: [PATCH 240/289] fix(mobile): route kiloclaw tab through instances --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 26 +++++++++++--- apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 36 ++----------------- .../kiloclaw/instance-entry-state.test.ts | 27 ++++++++++++++ .../kiloclaw/instance-entry-state.ts | 25 +++++++++++++ 4 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts create mode 100644 apps/mobile/src/components/kiloclaw/instance-entry-state.ts diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index e40ecd0330..f2b94a77ee 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -1,8 +1,10 @@ import { type Href, useRouter } from 'expo-router'; +import { useEffect } from 'react'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { EmptyStateContent } from '@/components/kiloclaw/empty-state-content'; +import { getKiloClawEntryDecision } from '@/components/kiloclaw/instance-entry-state'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; @@ -15,17 +17,33 @@ export default function KiloClawTab() { const router = useRouter(); const colors = useThemeColors(); const { data: instances } = useAllKiloClawInstances(); - const isEmpty = instances?.length === 0; - const onboardingQuery = useKiloClawMobileOnboardingState(isEmpty); + const entryDecision = getKiloClawEntryDecision(instances); + const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); useForegroundInvalidateKiloclawState(); + const redirectSandboxId = entryDecision.kind === 'redirect' ? entryDecision.sandboxId : null; + + useEffect(() => { + if (redirectSandboxId !== null) { + router.replace(`/(app)/chat/${redirectSandboxId}` as Href); + } + }, [redirectSandboxId, router]); + + const showInstanceSkeleton = + entryDecision.kind === 'loading' || + entryDecision.kind === 'redirect' || + entryDecision.kind === 'list' || + onboardingQuery.isPending; + return ( } /> - {onboardingQuery.isPending ? ( + {showInstanceSkeleton ? ( - + + + ) : ( diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index b16ae4347e..d3ac833371 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -1,14 +1,11 @@ import * as Haptics from 'expo-haptics'; -import { type Href, Tabs, useRouter } from 'expo-router'; +import { Tabs } from 'expo-router'; import { Bot, House, MessageSquare } from 'lucide-react-native'; -import { useEffect, useState } from 'react'; import { Platform, type TextStyle, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BlurBar } from '@/components/ui/blur-bar'; -import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; const ANDROID_TAB_BAR_EXTRA_PADDING = 4; const TAB_BAR_ITEM_CONTENT_WIDTH = 64; @@ -42,19 +39,6 @@ function TabBarBackground() { export default function TabsLayout() { const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); - const router = useRouter(); - const { data: instances } = useAllKiloClawInstances(); - const [lastActiveHydrated, setLastActiveHydrated] = useState(false); - - useEffect(() => { - void (async () => { - try { - await loadLastActiveInstance(); - } finally { - setLastActiveHydrated(true); - } - })(); - }, []); return ( { + tabPress: () => { void Haptics.selectionAsync(); - // While instances or the persisted last-active id are still loading, - // block the tab switch so the user doesn't briefly land on the - // (1_kiloclaw) empty state, and so we don't redirect into the wrong - // chat before the persisted instance has been hydrated. - if (instances === undefined || !lastActiveHydrated) { - e.preventDefault(); - return; - } - const first = instances[0]; - if (first) { - e.preventDefault(); - const lastId = getLastActiveInstance(); - const target = - lastId && instances.some(i => i.sandboxId === lastId) ? lastId : first.sandboxId; - router.push(`/(app)/chat/${target}` as Href); - } }, }} /> diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts new file mode 100644 index 0000000000..16a3d3e940 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getKiloClawEntryDecision } from './instance-entry-state'; + +const personal = { sandboxId: 'personal-1' }; +const org = { sandboxId: 'org-1' }; + +describe('getKiloClawEntryDecision', () => { + it('waits while instances are unresolved', () => { + expect(getKiloClawEntryDecision(undefined)).toEqual({ kind: 'loading' }); + }); + + it('shows onboarding when there are no instances', () => { + expect(getKiloClawEntryDecision([])).toEqual({ kind: 'empty' }); + }); + + it('redirects directly when exactly one instance exists', () => { + expect(getKiloClawEntryDecision([personal])).toEqual({ + kind: 'redirect', + sandboxId: 'personal-1', + }); + }); + + it('shows the picker when multiple instances exist', () => { + expect(getKiloClawEntryDecision([personal, org])).toEqual({ kind: 'list' }); + }); +}); diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts new file mode 100644 index 0000000000..e2de6196eb --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts @@ -0,0 +1,25 @@ +type InstanceLike = { + sandboxId: string; +}; + +export type KiloClawEntryDecision = + | { kind: 'loading' } + | { kind: 'empty' } + | { kind: 'redirect'; sandboxId: string } + | { kind: 'list' }; + +export function getKiloClawEntryDecision( + instances: readonly InstanceLike[] | undefined +): KiloClawEntryDecision { + if (instances === undefined) { + return { kind: 'loading' }; + } + if (instances.length === 0) { + return { kind: 'empty' }; + } + const first = instances[0]; + if (instances.length === 1 && first !== undefined) { + return { kind: 'redirect', sandboxId: first.sandboxId }; + } + return { kind: 'list' }; +} From a6b73e42f7c4bc5b64b2149e19506f75921abf12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:42:00 +0200 Subject: [PATCH 241/289] feat(mobile): add kiloclaw instance list --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 57 +++++++- .../components/kiloclaw/instance-list-row.tsx | 59 ++++++++ .../kiloclaw/instance-list-screen.tsx | 127 ++++++++++++++++++ .../src/components/kiloclaw/status-badge.tsx | 2 +- 4 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/instance-list-row.tsx create mode 100644 apps/mobile/src/components/kiloclaw/instance-list-screen.tsx diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index f2b94a77ee..9ad68fdde5 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -1,26 +1,38 @@ import { type Href, useRouter } from 'expo-router'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { EmptyStateContent } from '@/components/kiloclaw/empty-state-content'; import { getKiloClawEntryDecision } from '@/components/kiloclaw/instance-entry-state'; +import { InstanceListScreen } from '@/components/kiloclaw/instance-list-screen'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { useForegroundInvalidateKiloclawState } from '@/lib/hooks/use-foreground-invalidate-kiloclaw-state'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; export default function KiloClawTab() { const router = useRouter(); const colors = useThemeColors(); - const { data: instances } = useAllKiloClawInstances(); + const instancesQuery = useAllKiloClawInstances(); + const { data: instances } = instancesQuery; const entryDecision = getKiloClawEntryDecision(instances); const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); + const [currentSandboxId, setCurrentSandboxId] = useState(null); useForegroundInvalidateKiloclawState(); + useEffect(() => { + void (async () => { + await loadLastActiveInstance(); + setCurrentSandboxId(getLastActiveInstance()); + })(); + }, []); + const redirectSandboxId = entryDecision.kind === 'redirect' ? entryDecision.sandboxId : null; useEffect(() => { @@ -32,9 +44,48 @@ export default function KiloClawTab() { const showInstanceSkeleton = entryDecision.kind === 'loading' || entryDecision.kind === 'redirect' || - entryDecision.kind === 'list' || onboardingQuery.isPending; + if (instancesQuery.isError) { + return ( + + } + /> + + { + void instancesQuery.refetch(); + }} + /> + + + ); + } + + if (entryDecision.kind === 'list') { + return ( + { + void instancesQuery.refetch(); + }} + onSelect={sandboxId => { + router.push(`/(app)/chat/${sandboxId}` as Href); + }} + onCreate={() => { + router.push('/(app)/onboarding' as Href); + }} + /> + ); + } + return ( } /> diff --git a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx new file mode 100644 index 0000000000..ac992a60a3 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx @@ -0,0 +1,59 @@ +import { Building2, CheckCircle2, ChevronRight, UserRound } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { StatusBadge } from '@/components/kiloclaw/status-badge'; +import { Text } from '@/components/ui/text'; +import { type ClawInstance } from '@/lib/hooks/use-instance-context'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +export type InstanceListRowProps = { + instance: ClawInstance; + isCurrent: boolean; + onPress: (sandboxId: string) => void; +}; + +function instanceTitle(instance: ClawInstance): string { + return instance.name ?? 'KiloClaw instance'; +} + +function instanceSubtitle(instance: ClawInstance): string { + return instance.organizationName ?? 'Personal'; +} + +export function InstanceListRow({ instance, isCurrent, onPress }: Readonly) { + const colors = useThemeColors(); + const Icon = instance.organizationName ? Building2 : UserRound; + + return ( + { + onPress(instance.sandboxId); + }} + className="min-h-16 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 active:opacity-80" + > + + + + + + + {instanceTitle(instance)} + + {isCurrent ? : null} + + + + {instanceSubtitle(instance)} + + + + + + + ); +} diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx new file mode 100644 index 0000000000..4b889bd2c1 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -0,0 +1,127 @@ +import * as Haptics from 'expo-haptics'; +import { Plus } from 'lucide-react-native'; +import { RefreshControl, ScrollView, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { InstanceListRow } from '@/components/kiloclaw/instance-list-row'; +import { ProfileAvatarButton } from '@/components/profile-avatar-button'; +import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; +import { Eyebrow } from '@/components/ui/eyebrow'; +import { Text } from '@/components/ui/text'; +import { type ClawInstance } from '@/lib/hooks/use-instance-context'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type Props = { + instances: ClawInstance[]; + currentSandboxId: string | null; + onSelect: (sandboxId: string) => void; + onCreate: () => void; + refreshing: boolean; + onRefresh: () => void; +}; + +function splitInstances(instances: ClawInstance[]) { + return { + personal: instances.filter(instance => instance.organizationId === null), + organizations: instances.filter(instance => instance.organizationId !== null), + }; +} + +function InstanceSection({ + title, + instances, + currentSandboxId, + onSelect, +}: Readonly<{ + title: string; + instances: ClawInstance[]; + currentSandboxId: string | null; + onSelect: (sandboxId: string) => void; +}>) { + if (instances.length === 0) { + return null; + } + + return ( + + + {title} + + {instances.length} + + + + {instances.map(instance => ( + + ))} + + + ); +} + +export function InstanceListScreen({ + instances, + currentSandboxId, + onSelect, + onCreate, + refreshing, + onRefresh, +}: Readonly) { + const colors = useThemeColors(); + const { personal, organizations } = splitInstances(instances); + + function handleSelect(sandboxId: string) { + void Haptics.selectionAsync(); + onSelect(sandboxId); + } + + return ( + + } /> + + } + > + + + Choose an instance to view conversations. + + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/kiloclaw/status-badge.tsx b/apps/mobile/src/components/kiloclaw/status-badge.tsx index b94a2c2f96..136acb4d0d 100644 --- a/apps/mobile/src/components/kiloclaw/status-badge.tsx +++ b/apps/mobile/src/components/kiloclaw/status-badge.tsx @@ -55,7 +55,7 @@ export function statusLabel(status: StatusValue | string): string { export function StatusBadge({ status, className, -}: Readonly<{ status: StatusValue; className?: string }>) { +}: Readonly<{ status: StatusValue | string; className?: string }>) { const tone = statusTone(status); const label = statusLabel(status); From ad855ead967ce964d4182d065565e36e39d9328e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:46:38 +0200 Subject: [PATCH 242/289] feat(mobile): polish kilo chat conversations --- .../app/(app)/chat/[sandbox-id]/_layout.tsx | 14 +- .../chat/[sandbox-id]/rename-conversation.tsx | 44 ++++++ .../conversation-list-groups.test.ts | 64 +++++++++ .../kilo-chat/conversation-list-groups.ts | 54 +++++++ .../kilo-chat/conversation-list-screen.tsx | 136 +++++++++++------- .../components/kilo-chat/conversation-row.tsx | 125 ++++++++++++++++ .../kilo-chat/hooks/use-conversations.ts | 18 +++ .../kilo-chat/rename-conversation-sheet.tsx | 87 +++++++++++ .../kilo-chat-hooks/src/use-conversations.ts | 6 +- 9 files changed, 497 insertions(+), 51 deletions(-) create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-groups.ts create mode 100644 apps/mobile/src/components/kilo-chat/conversation-row.tsx create mode 100644 apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx index 779ef42566..9b8a7843d8 100644 --- a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx @@ -29,7 +29,19 @@ export default function ChatSandboxLayout() { <> - + + + + + ); } diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx new file mode 100644 index 0000000000..90a25099e9 --- /dev/null +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx @@ -0,0 +1,44 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; + +import { RenameConversationSheet } from '@/components/kilo-chat/rename-conversation-sheet'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { useRenameConversation } from '@/components/kilo-chat/hooks/use-conversations'; + +export default function RenameConversationRoute() { + const router = useRouter(); + const client = useKiloChatClient(); + const { + 'sandbox-id': sandboxId, + conversationId, + title, + } = useLocalSearchParams<{ + 'sandbox-id': string; + conversationId?: string; + title?: string; + }>(); + const renameConversation = useRenameConversation(client); + const initialTitle = typeof title === 'string' ? title : ''; + + return ( + { + router.back(); + }} + onSave={nextTitle => { + if (!conversationId) { + return; + } + renameConversation.mutate( + { conversationId, title: nextTitle, sandboxId }, + { + onSuccess: () => { + router.back(); + }, + } + ); + }} + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts new file mode 100644 index 0000000000..987e3a1d21 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.test.ts @@ -0,0 +1,64 @@ +import { type ConversationListItem } from '@kilocode/kilo-chat'; +import { describe, expect, it } from 'vitest'; + +import { groupConversationsByActivity } from './conversation-list-groups'; + +function conversation( + conversationId: string, + timestamp: number, + overrides: Partial = {} +): ConversationListItem { + return { + conversationId, + title: conversationId, + lastActivityAt: timestamp, + lastReadAt: null, + joinedAt: timestamp - 1000, + ...overrides, + }; +} + +describe('groupConversationsByActivity', () => { + it('groups conversations by local activity day', () => { + const todayStart = new Date(2026, 4, 4).getTime(); + const nowMs = todayStart + 12 * 60 * 60 * 1000; + const yesterday = todayStart - 60 * 60 * 1000; + const thisWeek = todayStart - 3 * 24 * 60 * 60 * 1000; + const older = todayStart - 8 * 24 * 60 * 60 * 1000; + + expect( + groupConversationsByActivity( + [ + conversation('today', nowMs), + conversation('yesterday', yesterday), + conversation('this-week', thisWeek), + conversation('older', older), + ], + nowMs + ) + ).toEqual([ + { label: 'Today', items: [conversation('today', nowMs)] }, + { label: 'Yesterday', items: [conversation('yesterday', yesterday)] }, + { label: 'This Week', items: [conversation('this-week', thisWeek)] }, + { label: 'Older', items: [conversation('older', older)] }, + ]); + }); + + it('uses joined time when last activity is missing', () => { + const todayStart = new Date(2026, 4, 4).getTime(); + const nowMs = todayStart + 12 * 60 * 60 * 1000; + const joinedAt = todayStart - 2 * 24 * 60 * 60 * 1000; + + expect( + groupConversationsByActivity( + [conversation('joined-only', nowMs, { lastActivityAt: null, joinedAt })], + nowMs + ) + ).toEqual([ + { + label: 'This Week', + items: [conversation('joined-only', nowMs, { lastActivityAt: null, joinedAt })], + }, + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts new file mode 100644 index 0000000000..9ac55ac3c1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts @@ -0,0 +1,54 @@ +import { type ConversationListItem } from '@kilocode/kilo-chat'; + +export type ConversationListGroupLabel = 'Today' | 'Yesterday' | 'This Week' | 'Older'; + +export type ConversationListGroup = { + label: ConversationListGroupLabel; + items: ConversationListItem[]; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const GROUP_LABELS: readonly ConversationListGroupLabel[] = [ + 'Today', + 'Yesterday', + 'This Week', + 'Older', +]; + +function conversationTimestamp(conversation: ConversationListItem): number { + return conversation.lastActivityAt ?? conversation.joinedAt; +} + +export function groupConversationsByActivity( + conversations: ConversationListItem[], + nowMs: number +): ConversationListGroup[] { + const now = new Date(nowMs); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - DAY_MS; + const weekStart = todayStart - 6 * DAY_MS; + const groups: Record = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + for (const conversation of conversations) { + const timestamp = conversationTimestamp(conversation); + if (timestamp >= todayStart) { + groups.Today.push(conversation); + } else if (timestamp >= yesterdayStart) { + groups.Yesterday.push(conversation); + } else if (timestamp >= weekStart) { + groups['This Week'].push(conversation); + } else { + groups.Older.push(conversation); + } + } + + return GROUP_LABELS.filter(label => groups[label].length > 0).map(label => ({ + label, + items: groups[label], + })); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index f9002bae93..e5699ba098 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -1,20 +1,28 @@ import { FlashList } from '@shopify/flash-list'; import { type Href, useRouter } from 'expo-router'; import { useCallback } from 'react'; -import { Pressable, View } from 'react-native'; +import { ActivityIndicator, Pressable, RefreshControl, View } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { timeAgo } from '@/lib/utils'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { Plus } from 'lucide-react-native'; import { EmptyConversationList } from './empty-conversation-list'; +import { groupConversationsByActivity } from './conversation-list-groups'; import { getConversationListContentState } from './conversation-list-state'; +import { ConversationRow } from './conversation-row'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; -import { useConversations, useCreateConversation } from './hooks/use-conversations'; +import { + useConversations, + useCreateConversation, + useLeaveConversation, +} from './hooks/use-conversations'; import { useInstancePresence } from './hooks/use-instance-presence'; +import { useNowTicker } from './hooks/use-now-ticker'; type Props = { sandboxId: string; @@ -22,21 +30,21 @@ type Props = { }; type ConversationItem = { - conversationId: string; - title: string | null; - lastActivityAt: number | null; - lastReadAt: number | null; - joinedAt: number; + kind: 'conversation'; + conversation: NonNullable['data']>['conversations'][number]; }; -type ConversationRowProps = { - item: ConversationItem; - onPress: (id: string) => void; +type ConversationHeaderItem = { + kind: 'header'; + label: string; }; -function ConversationListSkeleton() { +type ConversationListEntry = ConversationHeaderItem | ConversationItem; + +function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) { return ( + {showHeader ? : null} {[0, 1, 2, 3].map(i => ( @@ -50,43 +58,28 @@ function ConversationListSkeleton() { ); } -function ConversationRow({ item, onPress }: ConversationRowProps) { - const hasUnread = - item.lastActivityAt !== null && - (item.lastReadAt === null || item.lastReadAt < item.lastActivityAt); - - return ( - { - onPress(item.conversationId); - }} - > - - - {item.title ?? 'Untitled conversation'} - - {item.lastActivityAt !== null ? ( - - {timeAgo(new Date(item.lastActivityAt))} - - ) : null} - - {hasUnread ? ( - - ) : ( - // Reserve space so rows stay the same width whether the dot is shown or not - - )} - - ); +function flattenConversationGroups( + conversations: NonNullable['data']>['conversations'], + nowMs: number +): ConversationListEntry[] { + const entries: ConversationListEntry[] = []; + for (const group of groupConversationsByActivity(conversations, nowMs)) { + entries.push({ kind: 'header', label: group.label }); + for (const conversation of group.items) { + entries.push({ kind: 'conversation', conversation }); + } + } + return entries; } export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const router = useRouter(); + const colors = useThemeColors(); const client = useKiloChatClient(); const listQuery = useConversations(client, sandboxId); const createConversation = useCreateConversation(client); + const leaveConversation = useLeaveConversation(client); + const now = useNowTicker(60_000); const hasNextPage = listQuery.hasNextPage; const isFetchingNextPage = listQuery.isFetchingNextPage; @@ -109,6 +102,10 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { ); } + function handleLeave(conversationId: string) { + leaveConversation.mutate({ conversationId, sandboxId }); + } + const fetchMoreConversations = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { void fetchNextPage({ cancelRefetch: false }); @@ -126,7 +123,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { - + ); @@ -150,15 +147,50 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { } const conversations = listQuery.data?.conversations ?? []; + const entries = flattenConversationGroups(conversations, now); return ( - + + {createConversation.isPending ? ( + + ) : ( + + )} + + } + /> c.conversationId} - renderItem={({ item }) => } + data={entries} + keyExtractor={entry => + entry.kind === 'header' ? `header:${entry.label}` : entry.conversation.conversationId + } + renderItem={({ item }) => + item.kind === 'header' ? ( + + {item.label} + + ) : ( + + + + ) + } ListEmptyComponent={ - + ) : null } onEndReached={fetchMoreConversations} onEndReachedThreshold={0.5} + refreshControl={ + { + void listQuery.refetch(); + }} + /> + } /> diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx new file mode 100644 index 0000000000..dc230ed933 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -0,0 +1,125 @@ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { CONVERSATION_TITLE_MAX_CHARS, type ConversationListItem } from '@kilocode/kilo-chat'; +import * as Haptics from 'expo-haptics'; +import { type Href, useRouter } from 'expo-router'; +import { MoreVertical } from 'lucide-react-native'; +import { Alert, Pressable, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { timeAgo } from '@/lib/utils'; + +type ConversationRowProps = { + conversation: ConversationListItem; + sandboxId: string; + onPress: (conversationId: string) => void; + onLeave: (conversationId: string) => void; +}; + +function conversationTitle(conversation: ConversationListItem): string { + return conversation.title ?? 'Untitled conversation'; +} + +function conversationTimestamp(conversation: ConversationListItem): number { + return conversation.lastActivityAt ?? conversation.joinedAt; +} + +function hasUnread(conversation: ConversationListItem): boolean { + return ( + conversation.lastActivityAt !== null && + (conversation.lastReadAt === null || conversation.lastReadAt < conversation.lastActivityAt) + ); +} + +export function ConversationRow({ + conversation, + sandboxId, + onPress, + onLeave, +}: Readonly) { + const router = useRouter(); + const colors = useThemeColors(); + const { bottom } = useSafeAreaInsets(); + const { showActionSheetWithOptions } = useActionSheet(); + const title = conversationTitle(conversation); + + function openRenameSheet() { + const params = new URLSearchParams({ + conversationId: conversation.conversationId, + title: title.slice(0, CONVERSATION_TITLE_MAX_CHARS), + }); + router.push(`/(app)/chat/${sandboxId}/rename-conversation?${params.toString()}` as Href); + } + + function confirmLeave() { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + Alert.alert('Leave conversation?', 'This removes it from your list.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Leave', + style: 'destructive', + onPress: () => { + onLeave(conversation.conversationId); + }, + }, + ]); + } + + function openActions() { + void Haptics.selectionAsync(); + showActionSheetWithOptions( + { + title: title, + options: ['Rename', 'Leave', 'Cancel'], + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === 0) { + openRenameSheet(); + } else if (index === 1) { + confirmLeave(); + } + } + ); + } + + return ( + { + onPress(conversation.conversationId); + }} + > + + + + {title} + + {hasUnread(conversation) ? ( + + ) : null} + + + {timeAgo(new Date(conversationTimestamp(conversation)))} + + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts index b781ea82b7..915ddfe511 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts @@ -3,6 +3,8 @@ import { useConversationDetail, useConversations, useCreateConversation as useSharedCreateConversation, + useLeaveConversation as useSharedLeaveConversation, + useRenameConversation as useSharedRenameConversation, } from '@kilocode/kilo-chat-hooks'; import { toast } from 'sonner-native'; @@ -15,3 +17,19 @@ export function useCreateConversation(client: KiloChatClient) { }, }); } + +export function useRenameConversation(client: KiloChatClient) { + return useSharedRenameConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to rename conversation')); + }, + }); +} + +export function useLeaveConversation(client: KiloChatClient) { + return useSharedLeaveConversation(client, { + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to leave conversation')); + }, + }); +} diff --git a/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx new file mode 100644 index 0000000000..6f33437c26 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/rename-conversation-sheet.tsx @@ -0,0 +1,87 @@ +import { CONVERSATION_TITLE_MAX_CHARS } from '@kilocode/kilo-chat'; +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type RenameConversationSheetProps = { + initialTitle: string; + isSaving: boolean; + onCancel: () => void; + onSave: (title: string) => void; +}; + +function canSaveTitle(text: string, initialTitle: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.length > 0 && + trimmed.length <= CONVERSATION_TITLE_MAX_CHARS && + trimmed !== initialTitle.trim() + ); +} + +export function RenameConversationSheet({ + initialTitle, + isSaving, + onCancel, + onSave, +}: Readonly) { + const colors = useThemeColors(); + const titleRef = useRef(initialTitle); + const [canSave, setCanSave] = useState(false); + + function handleTextChange(text: string) { + titleRef.current = text; + setCanSave(canSaveTitle(text, initialTitle)); + } + + function handleSave() { + const title = titleRef.current.trim(); + if (canSaveTitle(title, initialTitle)) { + onSave(title); + } + } + + return ( + + + + Rename conversation + Set a short name for this thread. + + + + + Cancel + + + + + + ); +} diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts index 92ca2874c5..a7a0ee730d 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -104,7 +104,7 @@ type RenameConversationMutationVariables = { sandboxId: string | null; }; -export function useRenameConversation(client: KiloChatClient) { +export function useRenameConversation(client: KiloChatClient, options?: MutationErrorOptions) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: RenameConversationMutationVariables) => @@ -112,6 +112,7 @@ export function useRenameConversation(client: KiloChatClient) { onSuccess: (_data, variables) => { settleRenameConversation(queryClient, variables); }, + onError: options?.onError, }); } @@ -120,7 +121,7 @@ type LeaveConversationMutationVariables = { sandboxId: string | null; }; -export function useLeaveConversation(client: KiloChatClient) { +export function useLeaveConversation(client: KiloChatClient, options?: MutationErrorOptions) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: LeaveConversationMutationVariables) => @@ -128,6 +129,7 @@ export function useLeaveConversation(client: KiloChatClient) { onMutate: variables => applyOptimisticLeaveConversation(queryClient, variables), onError: (_err, _variables, context) => { rollbackOptimisticLeaveConversation(queryClient, context); + options?.onError?.(_err); }, onSuccess: (_data, variables) => { settleLeaveConversation(queryClient, variables); From ec2544623e849ca8bcad60271dae367dd43b446b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:49:55 +0200 Subject: [PATCH 243/289] fix(mobile): preserve kilo chat drafts --- .../kilo-chat/conversation-screen.tsx | 11 ++- .../kilo-chat/message-input-state.test.ts | 56 ++++++++++-- .../kilo-chat/message-input-state.ts | 50 +++++++++-- .../components/kilo-chat/message-input.tsx | 90 ++++++++++++++----- 4 files changed, 169 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index f8ac7f86eb..c7cef9d797 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { useActionSheet } from '@expo/react-native-action-sheet'; +import * as Haptics from 'expo-haptics'; import { attemptMarkCurrentConversationRead, clearMarkReadRetry, @@ -152,6 +153,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl onSuccess: () => { controls?.clearDraft(); setEditingMessage(null); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, onError: err => { toast.error(formatKiloChatError(err, 'Failed to edit message')); @@ -169,7 +171,13 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }), { onSuccess: () => { - setReplyingTo(null); + if (controls?.clearDraft() ?? false) { + setReplyingTo(null); + } + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + onError: err => { + toast.error(formatKiloChatError(err, 'Failed to send message')); }, } ); @@ -449,7 +457,6 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl disabled={inputAvailability.disabled} disabledReason={inputAvailability.disabledReason} initialText={editingText} - clearOnSubmit={editingMessage === null} replyingTo={replyingTo} onCancelReply={ replyingTo diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts index 8dffe4b48d..b12b83988b 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-state.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; -import { applyMessageInputTextChange, submitMessageInputDraft } from './message-input-state'; +import { + applyMessageInputTextChange, + shouldClearSubmittedDraft, + shouldShowMessageInputCounter, + submitMessageInputDraft, +} from './message-input-state'; describe('message input typing behavior', () => { it('sends typing notifications on text changes without preventing normal send', () => { @@ -37,11 +42,11 @@ describe('message input typing behavior', () => { }); expect(typingCount).toBe(1); - expect(submitted).toBe(true); + expect(submitted).toEqual({ text: 'hello', replyingToMessageId: 'reply-1' }); expect(sentMessages).toEqual([{ text: 'hello', replyTo: 'reply-1' }]); - expect(cleared).toBe(true); - expect(valueRef.current).toBe(''); - expect(canSendValues).toEqual([true, false]); + expect(cleared).toBe(false); + expect(valueRef.current).toBe(' hello '); + expect(canSendValues).toEqual([true]); }); it('keeps over-limit drafts intact and does not submit them', () => { @@ -72,7 +77,7 @@ describe('message input typing behavior', () => { }, }); - expect(submitted).toBe(false); + expect(submitted).toBeNull(); expect(sentMessages).toEqual([]); expect(cleared).toBe(false); expect(valueRef.current).toBe(overLimitText); @@ -99,7 +104,7 @@ describe('message input typing behavior', () => { clearOnSubmit: false, }); - expect(submitted).toBe(true); + expect(submitted).toEqual({ text: 'edited draft', replyingToMessageId: undefined }); expect(sentMessages).toEqual(['edited draft']); expect(cleared).toBe(false); expect(valueRef.current).toBe(' edited draft '); @@ -109,7 +114,7 @@ describe('message input typing behavior', () => { it('lets edit callers clear drafts after successful mutation', () => { const valueRef = { current: ' edited draft ' }; const canSendValues: boolean[] = []; - const successControls: { clearDraft?: () => void } = {}; + const successControls: { clearDraft?: () => boolean } = {}; let cleared = false; const submitted = submitMessageInputDraft({ @@ -135,9 +140,42 @@ describe('message input typing behavior', () => { } clearDraft(); - expect(submitted).toBe(true); + expect(submitted).toEqual({ text: 'edited draft', replyingToMessageId: undefined }); expect(cleared).toBe(true); expect(valueRef.current).toBe(''); expect(canSendValues).toEqual([false]); }); + + it('shows the character counter at eighty percent of the text limit', () => { + expect(shouldShowMessageInputCounter('x'.repeat(MESSAGE_TEXT_MAX_CHARS * 0.8 - 1))).toBe(false); + expect(shouldShowMessageInputCounter('x'.repeat(MESSAGE_TEXT_MAX_CHARS * 0.8))).toBe(true); + }); + + it('clears a submitted draft only when the visible draft and reply target still match', () => { + const submitted = { text: 'hello', replyingToMessageId: 'reply-1' }; + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello', + currentReplyingToMessageId: 'reply-1', + submitted, + }) + ).toBe(true); + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello again', + currentReplyingToMessageId: 'reply-1', + submitted, + }) + ).toBe(false); + + expect( + shouldClearSubmittedDraft({ + currentText: 'hello', + currentReplyingToMessageId: undefined, + submitted, + }) + ).toBe(false); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.ts b/apps/mobile/src/components/kilo-chat/message-input-state.ts index 905cfebe3e..85e6907654 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-state.ts @@ -3,13 +3,40 @@ import { MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; type DraftRef = { current: string }; export type MessageInputSubmitControls = { - clearDraft: () => void; + clearDraft: () => boolean; +}; + +export type SubmittedMessageDraft = { + text: string; + replyingToMessageId?: string; }; function canSubmitDraft(text: string): boolean { return text.trim().length > 0 && text.length <= MESSAGE_TEXT_MAX_CHARS; } +export function shouldShowMessageInputCounter(text: string): boolean { + return text.length >= MESSAGE_TEXT_MAX_CHARS * 0.8; +} + +export function isMessageInputOverLimit(text: string): boolean { + return text.length > MESSAGE_TEXT_MAX_CHARS; +} + +export function shouldClearSubmittedDraft({ + currentText, + currentReplyingToMessageId, + submitted, +}: { + currentText: string; + currentReplyingToMessageId?: string; + submitted: SubmittedMessageDraft; +}): boolean { + return ( + currentText === submitted.text && currentReplyingToMessageId === submitted.replyingToMessageId + ); +} + export function applyMessageInputTextChange({ text, valueRef, @@ -32,7 +59,8 @@ export function submitMessageInputDraft({ onSend, clearInput, setCanSend, - clearOnSubmit = true, + getCurrentReplyingToMessageId, + clearOnSubmit = false, }: { valueRef: DraftRef; replyingToMessageId?: string; @@ -43,22 +71,34 @@ export function submitMessageInputDraft({ ) => void; clearInput: () => void; setCanSend: (canSend: boolean) => void; + getCurrentReplyingToMessageId?: () => string | undefined; clearOnSubmit?: boolean; -}) { +}): SubmittedMessageDraft | null { const draft = valueRef.current; if (!canSubmitDraft(draft)) { - return false; + return null; } const text = draft.trim(); + const submitted: SubmittedMessageDraft = { text, replyingToMessageId }; const clearDraft = () => { + if ( + !shouldClearSubmittedDraft({ + currentText: valueRef.current.trim(), + currentReplyingToMessageId: getCurrentReplyingToMessageId?.() ?? replyingToMessageId, + submitted, + }) + ) { + return false; + } valueRef.current = ''; clearInput(); setCanSend(false); + return true; }; onSend(text, replyingToMessageId, { clearDraft }); if (clearOnSubmit) { clearDraft(); } - return true; + return submitted; } diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 6647bac24d..8006d850d2 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -1,14 +1,16 @@ import { Send, X } from 'lucide-react-native'; import { useRef, useState } from 'react'; import { Pressable, TextInput, View } from 'react-native'; -import { type Message } from '@kilocode/kilo-chat'; +import { type Message, MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { applyMessageInputTextChange, + isMessageInputOverLimit, type MessageInputSubmitControls, + shouldShowMessageInputCounter, submitMessageInputDraft, } from './message-input-state'; import { getReplyPreviewText } from './message-presentation'; @@ -29,6 +31,24 @@ type Props = { clearOnSubmit?: boolean; }; +function resolveSendDisabled({ + canSend, + disabled, + overLimit, +}: { + canSend: boolean; + disabled?: boolean; + overLimit: boolean; +}): boolean { + if (!canSend) { + return true; + } + if (disabled === true) { + return true; + } + return overLimit; +} + export function MessageInput({ onSend, onTyping, @@ -43,7 +63,13 @@ export function MessageInput({ const colors = useThemeColors(); const valueRef = useRef(initialText); const [canSend, setCanSend] = useState(initialText.trim().length > 0); + const [draftLength, setDraftLength] = useState(initialText.length); const inputRef = useRef(null); + const currentReplyingToRef = useRef(replyingTo?.id); + currentReplyingToRef.current = replyingTo?.id; + const overLimit = isMessageInputOverLimit(valueRef.current); + const showCounter = shouldShowMessageInputCounter(valueRef.current); + const sendDisabled = resolveSendDisabled({ canSend, disabled, overLimit }); const submit = () => { if (disabled) { @@ -53,8 +79,12 @@ export function MessageInput({ valueRef, replyingToMessageId: replyingTo?.id, onSend, - clearInput: () => inputRef.current?.clear(), + clearInput: () => { + inputRef.current?.clear(); + setDraftLength(0); + }, setCanSend, + getCurrentReplyingToMessageId: () => currentReplyingToRef.current, clearOnSubmit, }); }; @@ -87,24 +117,40 @@ export function MessageInput({ )} - { - applyMessageInputTextChange({ - text: t, - valueRef, - setCanSend, - onTyping, - }); - }} - onSubmitEditing={submit} - /> + + 160} + editable={!disabled} + onChangeText={t => { + setDraftLength(t.length); + applyMessageInputTextChange({ + text: t, + valueRef, + setCanSend, + onTyping, + }); + }} + onSubmitEditing={submit} + /> + + {showCounter ? ( + + {draftLength}/{MESSAGE_TEXT_MAX_CHARS} + + ) : null} + + {onCancelEdit && ( From 3d65d1b1c5028a12d5f6f3dc6cecd53f9f28d25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:55:50 +0200 Subject: [PATCH 244/289] feat(mobile): complete kilo chat message actions --- .../kilo-chat/conversation-screen.tsx | 63 +++++++++--- .../kilo-chat/message-actions.test.ts | 37 ++++++- .../components/kilo-chat/message-actions.ts | 8 ++ .../components/kilo-chat/message-bubble.tsx | 32 ++++-- .../components/kilo-chat/message-markdown.tsx | 24 +++++ .../kilo-chat/message-presentation.test.ts | 11 +++ .../kilo-chat/message-presentation.ts | 8 ++ .../message-reaction-picker-sheet.tsx | 97 +++++++++++++++++++ 8 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-markdown.tsx create mode 100644 apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index c7cef9d797..825d1b7a6e 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { useActionSheet } from '@expo/react-native-action-sheet'; +import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import { attemptMarkCurrentConversationRead, @@ -19,6 +20,7 @@ import { } from '@kilocode/kilo-chat-hooks'; import { buildMessageActionAvailability, + contentBlocksToText, type ExecApprovalDecision, formatKiloChatError, type Message, @@ -38,8 +40,10 @@ import { buildMessageActionSheetOptions, getSelectedMessageAction } from './mess import { MessageInput } from './message-input'; import { type MessageInputSubmitControls } from './message-input-state'; import { MessageList } from './message-list'; +import { MessageReactionPickerSheet } from './message-reaction-picker-sheet'; import { buildSendMessageVariables, + canCopyMessage, canToggleReaction, createSendMessageClientId, } from './message-presentation'; @@ -84,6 +88,8 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const { bottom } = useSafeAreaInsets(); const [editingMessage, setEditingMessage] = useState(null); const [replyingTo, setReplyingTo] = useState(null); + const [reactionPickerMessage, setReactionPickerMessage] = useState(null); + const [recentReactions, setRecentReactions] = useState([]); const [pendingAction, setPendingAction] = useState(null); const pendingActionRef = useRef(null); const instanceContext = useInstanceContext(sandboxId); @@ -210,9 +216,19 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl } ); } + setRecentReactions(previous => [emoji, ...previous.filter(reaction => reaction !== emoji)]); + void Haptics.selectionAsync(); }, [addReaction, currentUserId, removeReaction] ); + const handleCopyMessage = useCallback(async (message: Message) => { + try { + await Clipboard.setStringAsync(contentBlocksToText(message.content)); + toast.success('Copied'); + } catch { + toast.error('Failed to copy'); + } + }, []); const handleExecuteAction = useCallback( (message: Message, groupId: string, value: ExecApprovalDecision) => { const nextPendingAction = { messageId: message.id, groupId }; @@ -235,16 +251,16 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl ); const handleLongPressMessage = useCallback( (message: Message) => { - if (message.deleted) { - return; - } const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; const actionAvailability = buildMessageActionAvailability(message, isOwnMessage); + const isPendingMessage = message.id.startsWith('pending-'); const actionSheet = buildMessageActionSheetOptions({ canReact: currentUserId !== null && actionAvailability.canReact, canReply: actionAvailability.canReply, + canCopy: canCopyMessage(message), canEdit: actionAvailability.canEdit, canDelete: actionAvailability.canDelete, + isPendingMessage, }); showActionSheetWithOptions( { @@ -261,14 +277,15 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl } if (selectedAction.kind === 'reaction') { - addReaction.mutate( - { messageId: message.id, emoji: selectedAction.emoji }, - { - onError: err => { - toast.error(formatKiloChatError(err, 'Failed to add reaction')); - }, - } - ); + handleReactionPress(message, selectedAction.emoji); + return; + } + if (selectedAction.kind === 'more-reactions') { + setReactionPickerMessage(message); + return; + } + if (selectedAction.kind === 'copy') { + void handleCopyMessage(message); return; } if (selectedAction.kind === 'reply') { @@ -302,7 +319,15 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl } ); }, - [addReaction, bottom, conversationId, currentUserId, deleteMessage, showActionSheetWithOptions] + [ + bottom, + conversationId, + currentUserId, + deleteMessage, + handleCopyMessage, + handleReactionPress, + showActionSheetWithOptions, + ] ); useConversationPresence(sandboxId, conversationId); @@ -474,6 +499,20 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl } /> + { + setReactionPickerMessage(null); + }} + onSelect={emoji => { + const message = reactionPickerMessage; + if (message) { + handleReactionPress(message, emoji); + } + setReactionPickerMessage(null); + }} + /> ); } diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts index 93eaba5612..2e42fd815c 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -7,6 +7,7 @@ describe('buildMessageActionSheetOptions', () => { const options = buildMessageActionSheetOptions({ canReact: true, canReply: true, + canCopy: true, canEdit: false, canDelete: false, }); @@ -20,12 +21,14 @@ describe('buildMessageActionSheetOptions', () => { const ownOptions = buildMessageActionSheetOptions({ canReact: true, canReply: true, + canCopy: true, canEdit: true, canDelete: true, }); const otherOptions = buildMessageActionSheetOptions({ canReact: true, canReply: true, + canCopy: true, canEdit: false, canDelete: false, }); @@ -42,12 +45,14 @@ describe('buildMessageActionSheetOptions', () => { const replyableOptions = buildMessageActionSheetOptions({ canReact: true, canReply: true, + canCopy: true, canEdit: false, canDelete: false, }); const failedDeliveryOptions = buildMessageActionSheetOptions({ canReact: true, canReply: false, + canCopy: true, canEdit: false, canDelete: false, }); @@ -60,6 +65,7 @@ describe('buildMessageActionSheetOptions', () => { const actionSheet = buildMessageActionSheetOptions({ canReact: false, canReply: true, + canCopy: false, canEdit: false, canDelete: false, }); @@ -72,6 +78,7 @@ describe('buildMessageActionSheetOptions', () => { const actionSheet = buildMessageActionSheetOptions({ canReact: false, canReply: true, + canCopy: false, canEdit: false, canDelete: false, }); @@ -86,6 +93,7 @@ describe('buildMessageActionSheetOptions', () => { const actionSheet = buildMessageActionSheetOptions({ canReact: true, canReply: true, + canCopy: false, canEdit: true, canDelete: true, isPendingMessage: true, @@ -101,18 +109,20 @@ describe('buildMessageActionSheetOptions', () => { const actionSheet = buildMessageActionSheetOptions({ canReact: false, canReply: false, + canCopy: true, canEdit: false, canDelete: true, }); - expect(actionSheet.options).toEqual(['Delete', 'Cancel']); - expect(actionSheet.destructiveButtonIndex).toBe(0); + expect(actionSheet.options).toEqual(['Copy', 'Delete', 'Cancel']); + expect(actionSheet.destructiveButtonIndex).toBe(1); }); it('offers cancel only for non-own delivery-failed messages', () => { const actionSheet = buildMessageActionSheetOptions({ canReact: false, canReply: false, + canCopy: false, canEdit: false, canDelete: false, }); @@ -120,4 +130,27 @@ describe('buildMessageActionSheetOptions', () => { expect(actionSheet.options).toEqual(['Cancel']); expect(actionSheet.destructiveButtonIndex).toBeUndefined(); }); + + it('orders reactions, reply, copy, edit, delete, then cancel', () => { + const actionSheet = buildMessageActionSheetOptions({ + canReact: true, + canReply: true, + canCopy: true, + canEdit: true, + canDelete: true, + }); + + expect(actionSheet.options).toEqual([ + '👍 React', + '❤️ React', + '😂 React', + '🎉 React', + 'More reactions', + 'Reply', + 'Copy', + 'Edit', + 'Delete', + 'Cancel', + ]); + }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index 6651b294ab..8caeaf64d4 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -4,7 +4,9 @@ type ReactionEmoji = (typeof FIRST_REACTION_EMOJIS)[number]; type MessageAction = | { kind: 'reaction'; label: string; emoji: ReactionEmoji } + | { kind: 'more-reactions'; label: 'More reactions' } | { kind: 'reply'; label: 'Reply' } + | { kind: 'copy'; label: 'Copy' } | { kind: 'edit'; label: 'Edit' } | { kind: 'delete'; label: 'Delete' } | { kind: 'cancel'; label: 'Cancel' }; @@ -12,6 +14,7 @@ type MessageAction = type BuildMessageActionSheetOptionsInput = { canReact: boolean; canReply: boolean; + canCopy: boolean; canEdit: boolean; canDelete: boolean; isPendingMessage?: boolean; @@ -20,6 +23,7 @@ type BuildMessageActionSheetOptionsInput = { export function buildMessageActionSheetOptions({ canReact, canReply, + canCopy, canEdit, canDelete, isPendingMessage = false, @@ -35,10 +39,14 @@ export function buildMessageActionSheetOptions({ for (const emoji of FIRST_REACTION_EMOJIS) { actions.push({ kind: 'reaction', label: `${emoji} React`, emoji }); } + actions.push({ kind: 'more-reactions', label: 'More reactions' }); } if (canUseApiBackedActions && canReply) { actions.push({ kind: 'reply', label: 'Reply' }); } + if (canCopy) { + actions.push({ kind: 'copy', label: 'Copy' }); + } if (canUseApiBackedActions && canEdit) { actions.push({ kind: 'edit', label: 'Edit' }); } diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index aefdbc66b4..e52bc1e80b 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -1,14 +1,18 @@ import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react-native'; import { memo } from 'react'; import { Pressable, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; +import { MessageMarkdown } from './message-markdown'; import { canShowReactionPills, getDeliveryFailureLabel, getReplyPreviewText, + isMessageEdited, type ReplyPreviewSource, } from './message-presentation'; @@ -51,8 +55,11 @@ function MessageBubbleComponent({ onReactionPress, onLongPress, }: Props) { + const colors = useThemeColors(); const isPending = message.id.startsWith('pending-'); const timestamp = message.clientUpdatedAt ?? message.updatedAt; + const edited = isMessageEdited(message); + const authorLabel = message.senderId.startsWith('bot:') ? 'KiloClaw' : message.senderId; function handleReactionPress(emoji: string) { onReactionPress(message, emoji); @@ -78,7 +85,7 @@ function MessageBubbleComponent({ > {showAuthor && ( - {message.senderId} + {authorLabel} {timestamp !== null && ( {formatTimestamp(timestamp)} )} @@ -109,19 +116,20 @@ function MessageBubbleComponent({ )} {message.content.map((block, index) => { if (block.type === 'text') { - return ( - - {block.text} - - ); + return ; } // block.type === 'actions' if (block.resolved) { const resolvedAction = block.actions.find(a => a.value === block.resolved?.value); const label = resolvedAction?.label ?? block.resolved.value; + const Icon = block.resolved.value.startsWith('allow') ? CheckCircle2 : XCircle; return ( + {label} ); @@ -146,9 +154,12 @@ function MessageBubbleComponent({ ); })} {deliveryFailureLabel && ( - - {deliveryFailureLabel} - + + + + {deliveryFailureLabel} + + )} )} @@ -161,6 +172,7 @@ function MessageBubbleComponent({ )} > {formatTimestamp(timestamp)} + {edited ? ' (edited)' : ''} )} @@ -181,7 +193,7 @@ function MessageBubbleComponent({ handleReactionPress(reaction.emoji); }} className={cn( - 'flex-row items-center gap-0.5 rounded-full px-2 py-0.5', + 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1', hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' )} > diff --git a/apps/mobile/src/components/kilo-chat/message-markdown.tsx b/apps/mobile/src/components/kilo-chat/message-markdown.tsx new file mode 100644 index 0000000000..c790fa5603 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-markdown.tsx @@ -0,0 +1,24 @@ +import { Text } from '@/components/ui/text'; + +import { MarkdownText } from '../agents/markdown-text'; + +type MessageMarkdownProps = { + text: string; + isFromMe: boolean; +}; + +export function MessageMarkdown({ text, isFromMe }: Readonly) { + if (text.trim().length === 0) { + return null; + } + + try { + return ; + } catch { + return ( + + {text} + + ); + } +} diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index f9a4b0b309..eed4e79979 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -8,6 +8,7 @@ import { createSendMessageClientId, getDeliveryFailureLabel, getReplyPreviewText, + isMessageEdited, } from './message-presentation'; vi.mock('expo-crypto', () => ({ @@ -104,6 +105,16 @@ describe('getDeliveryFailureLabel', () => { }); }); +describe('isMessageEdited', () => { + it('marks updated non-deleted messages as edited', () => { + expect(isMessageEdited(message({ clientUpdatedAt: 123 }))).toBe(true); + }); + + it('hides edited state for deleted messages', () => { + expect(isMessageEdited(message({ clientUpdatedAt: 123, deleted: true }))).toBe(false); + }); +}); + describe('canShowReactionPills', () => { it('hides reactions for deleted messages', () => { expect( diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index 764100019c..f1a6267002 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -74,3 +74,11 @@ export function canShowReactionPills(message: Message): boolean { export function canToggleReaction(message: Message, currentUserId: string | null): boolean { return currentUserId !== null && !message.deleted && !message.deliveryFailed; } + +export function canCopyMessage(message: Message): boolean { + return !message.deleted && contentBlocksToPreviewText(message.content).trim().length > 0; +} + +export function isMessageEdited(message: Message): boolean { + return !message.deleted && message.clientUpdatedAt !== null; +} diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx new file mode 100644 index 0000000000..a411e61076 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-reaction-picker-sheet.tsx @@ -0,0 +1,97 @@ +import { Portal } from '@rn-primitives/portal'; +import { X } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +const COMMON_REACTIONS = ['👍', '👎', '❤️', '😂', '🎉', '🚀', '👀', '✅', '🔥', '🙏', '💡', '🤔']; + +type MessageReactionPickerSheetProps = { + visible: boolean; + recentReactions: string[]; + onClose: () => void; + onSelect: (emoji: string) => void; +}; + +function uniqueReactions(reactions: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const reaction of reactions) { + if (!seen.has(reaction)) { + seen.add(reaction); + result.push(reaction); + } + } + return result; +} + +export function MessageReactionPickerSheet({ + visible, + recentReactions, + onClose, + onSelect, +}: Readonly) { + const colors = useThemeColors(); + if (!visible) { + return null; + } + + const recent = uniqueReactions(recentReactions).slice(0, 6); + + return ( + + + + + + Reactions + + + + + {recent.length > 0 ? ( + + ) : null} + + + + + ); +} + +function ReactionGrid({ + title, + reactions, + onSelect, +}: Readonly<{ + title: string; + reactions: string[]; + onSelect: (emoji: string) => void; +}>) { + return ( + + {title} + + {reactions.map(reaction => ( + { + onSelect(reaction); + }} + > + {reaction} + + ))} + + + ); +} From 0e89e51c52e16b55ba28b48db045faf1215cd8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 15:58:12 +0200 Subject: [PATCH 245/289] feat(mobile): polish kilo chat shell --- .../src/app/(app)/chat/[sandbox-id]/index.tsx | 3 +- .../src/app/(app)/chat/instance-picker.tsx | 74 ++++++++++++------ .../kilo-chat/conversation-header.tsx | 46 +++++++++++- .../kilo-chat/conversation-screen.tsx | 75 +++++++++++++++++-- 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx index d99324b468..347876e7e2 100644 --- a/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx +++ b/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx @@ -6,6 +6,7 @@ import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; export default function ChatSandboxIndex() { const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); const { data: instances } = useAllKiloClawInstances(); - const sandboxLabel = instances?.find(i => i.sandboxId === sandboxId)?.name ?? 'Chat'; + const instance = instances?.find(i => i.sandboxId === sandboxId); + const sandboxLabel = instance?.name ?? instance?.organizationName ?? 'Chat'; return ; } diff --git a/apps/mobile/src/app/(app)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/chat/instance-picker.tsx index c80568f506..a6af7f15bc 100644 --- a/apps/mobile/src/app/(app)/chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/chat/instance-picker.tsx @@ -4,16 +4,18 @@ import { Check } from 'lucide-react-native'; import { Pressable, ScrollView, View } from 'react-native'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; +import { QueryError } from '@/components/query-error'; +import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; -import { type InstanceStatus } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export default function InstancePickerScreen() { const router = useRouter(); const colors = useThemeColors(); const { currentId } = useLocalSearchParams<{ currentId: string }>(); - const { data: instances } = useAllKiloClawInstances(); + const instancesQuery = useAllKiloClawInstances(); + const { data: instances } = instancesQuery; const handleSelect = (sandboxId: string) => { void Haptics.selectionAsync(); @@ -44,28 +46,52 @@ export default function InstancePickerScreen() { - {(instances ?? []).map(instance => { - const isCurrent = instance.sandboxId === currentId; - return ( - { - handleSelect(instance.sandboxId); - }} - accessibilityRole="button" - accessibilityLabel={`${instance.name ?? instance.sandboxId}${isCurrent ? ', current' : ''}`} - > - - - {instance.name ?? instance.sandboxId} - - - - {isCurrent && } - - ); - })} + {instancesQuery.isPending ? ( + + + + + + ) : null} + {instancesQuery.isError ? ( + { + void instancesQuery.refetch(); + }} + /> + ) : null} + {!instancesQuery.isPending && !instancesQuery.isError + ? (instances ?? []).map(instance => { + const isCurrent = instance.sandboxId === currentId; + const title = instance.name ?? instance.organizationName ?? 'KiloClaw instance'; + return ( + { + handleSelect(instance.sandboxId); + }} + accessibilityRole="button" + accessibilityLabel={`${title}${isCurrent ? ', current' : ''}`} + > + + + {title} + + + + {instance.organizationName ?? 'Personal'} + + + + + {isCurrent ? : null} + + ); + }) + : null} ); } diff --git a/apps/mobile/src/components/kilo-chat/conversation-header.tsx b/apps/mobile/src/components/kilo-chat/conversation-header.tsx index bd69c1f476..1524228f2f 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-header.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-header.tsx @@ -1,14 +1,52 @@ +import { MoreVertical, Shuffle } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -type Props = { title: string; subtitle?: string }; +type Props = { + title: string; + subtitle?: string; + canSwitchInstance?: boolean; + onSwitchInstance?: () => void; + onOpenOptions?: () => void; +}; -export function ConversationHeader({ title, subtitle }: Props) { +export function ConversationHeader({ + title, + subtitle, + canSwitchInstance, + onSwitchInstance, + onOpenOptions, +}: Props) { + const colors = useThemeColors(); return ( {subtitle} : undefined + + {canSwitchInstance ? ( + + + + ) : null} + {onOpenOptions ? ( + + + + ) : null} + } /> ); diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 825d1b7a6e..b7dc99fba7 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -27,7 +27,7 @@ import { } from '@kilocode/kilo-chat'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; -import { useFocusEffect } from 'expo-router'; +import { type Href, useFocusEffect, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -50,6 +50,7 @@ import { import { getMessageHistoryContentState } from './message-history-state'; import { useConversationPresence } from './hooks/use-conversation-presence'; import { useConversationEventSubscription } from './hooks/use-conversation-event-subscription'; +import { useLeaveConversation } from './hooks/use-conversations'; import { useMobileTypingState, useTypingSender } from './hooks/use-typing'; import { useKiloChatClient } from './hooks/use-kilo-chat-client'; import { useAppActiveAndFocused } from './hooks/use-app-active-and-focused'; @@ -58,7 +59,7 @@ import { useMessageCacheUpdater, useMessages, useSendMessage } from './hooks/use import { useNowTicker } from './hooks/use-now-ticker'; import { useCurrentUserId } from './hooks/use-current-user-id'; import { TypingIndicator } from './typing-indicator'; -import { useInstanceContext } from '@/lib/hooks/use-instance-context'; +import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; import { setActiveChatLocation } from '@/lib/notifications'; @@ -83,6 +84,7 @@ function MessageHistorySkeleton() { export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { const client = useKiloChatClient(); + const router = useRouter(); const currentUserId = useCurrentUserId(); const { showActionSheetWithOptions } = useActionSheet(); const { bottom } = useSafeAreaInsets(); @@ -118,6 +120,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [messagesQuery]); const sendMutation = useSendMessage(client, conversationId, currentUserId); + const leaveConversation = useLeaveConversation(client); const editMessage = useEditMessage(client, conversationId); const deleteMessage = useDeleteMessage(client, conversationId); const executeAction = useExecuteAction(client, conversationId, currentUserId); @@ -142,6 +145,62 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl pendingMutation: sendMutation.isPending || editMessage.isPending, editing: editingMessage !== null, }); + const { data: instances } = useAllKiloClawInstances(); + const currentInstance = instances?.find(instance => instance.sandboxId === sandboxId); + const canSwitchInstance = (instances?.length ?? 0) > 1; + const instanceLabel = currentInstance?.name ?? currentInstance?.organizationName ?? 'KiloClaw'; + + const handleSwitchInstance = useCallback(() => { + router.push(`/(app)/chat/instance-picker?currentId=${sandboxId}` as Href); + }, [router, sandboxId]); + + const handleOpenConversationOptions = useCallback(() => { + void Haptics.selectionAsync(); + showActionSheetWithOptions( + { + title: conversationTitle, + options: ['Rename', 'Leave', 'Cancel'], + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + containerStyle: { paddingBottom: bottom }, + }, + index => { + if (index === 0) { + const params = new URLSearchParams({ conversationId, title: conversationTitle }); + router.push(`/(app)/chat/${sandboxId}/rename-conversation?${params.toString()}` as Href); + return; + } + if (index === 1) { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + Alert.alert('Leave conversation?', 'This removes it from your list.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Leave', + style: 'destructive', + onPress: () => { + leaveConversation.mutate( + { conversationId, sandboxId }, + { + onSuccess: () => { + router.replace(`/(app)/chat/${sandboxId}` as Href); + }, + } + ); + }, + }, + ]); + } + } + ); + }, [ + bottom, + conversationId, + conversationTitle, + leaveConversation, + router, + sandboxId, + showActionSheetWithOptions, + ]); const handleSend = useCallback( (text: string, inReplyToMessageId?: string, controls?: MessageInputSubmitControls) => { if (!editingMessage && inputAvailability.disabled) { @@ -426,7 +485,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl if (messageHistoryState === 'loading') { return ( - + - + - + Date: Mon, 4 May 2026 16:00:01 +0200 Subject: [PATCH 246/289] fix(mobile): address kiloclaw chat qa --- .../src/components/kilo-chat/conversation-list-groups.ts | 4 ++-- apps/mobile/src/components/kilo-chat/message-input-state.ts | 2 +- apps/mobile/src/components/kiloclaw/instance-entry-state.ts | 2 +- apps/mobile/src/components/kiloclaw/instance-list-row.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts index 9ac55ac3c1..100c16977d 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts @@ -1,8 +1,8 @@ import { type ConversationListItem } from '@kilocode/kilo-chat'; -export type ConversationListGroupLabel = 'Today' | 'Yesterday' | 'This Week' | 'Older'; +type ConversationListGroupLabel = 'Today' | 'Yesterday' | 'This Week' | 'Older'; -export type ConversationListGroup = { +type ConversationListGroup = { label: ConversationListGroupLabel; items: ConversationListItem[]; }; diff --git a/apps/mobile/src/components/kilo-chat/message-input-state.ts b/apps/mobile/src/components/kilo-chat/message-input-state.ts index 85e6907654..eea82eee49 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-input-state.ts @@ -6,7 +6,7 @@ export type MessageInputSubmitControls = { clearDraft: () => boolean; }; -export type SubmittedMessageDraft = { +type SubmittedMessageDraft = { text: string; replyingToMessageId?: string; }; diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts index e2de6196eb..113403d61e 100644 --- a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts @@ -2,7 +2,7 @@ type InstanceLike = { sandboxId: string; }; -export type KiloClawEntryDecision = +type KiloClawEntryDecision = | { kind: 'loading' } | { kind: 'empty' } | { kind: 'redirect'; sandboxId: string } diff --git a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx index ac992a60a3..cee2555358 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx @@ -6,7 +6,7 @@ import { Text } from '@/components/ui/text'; import { type ClawInstance } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -export type InstanceListRowProps = { +type InstanceListRowProps = { instance: ClawInstance; isCurrent: boolean; onPress: (sandboxId: string) => void; From 0a79e3437af4e851f3f8507a8d558b6305b1d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:16:17 +0200 Subject: [PATCH 247/289] fix(mobile): show greeting in home header --- apps/mobile/src/components/home/home-screen.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index c5a6fff84e..0d7976204b 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -96,7 +96,7 @@ export function HomeScreen() { const hasInstance = (instances?.length ?? 0) > 0; const isFirstTime = !hasInstance && !hasAnySession && !instancesError; - const title = isFirstTime ? 'Welcome to Kilo' : buildTimedGreeting(null); + const headerTitle = buildTimedGreeting(null); const handleRefresh = useCallback(() => { void (async () => { @@ -111,14 +111,20 @@ export function HomeScreen() { return ( - } /> + } + /> } > - + {isFirstTime ? : null} {isLoading ? ( From 38817c7a7233f5742b8cf0898c4b685a54250470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:38:38 +0200 Subject: [PATCH 248/289] fix(mobile): keep kiloclaw navigation in tabs --- .../app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx | 18 +++++++++++- .../chat/[sandbox-id]/[conversation-id].tsx | 5 ++-- .../chat/[sandbox-id]/_layout.tsx | 0 .../(1_kiloclaw)}/chat/[sandbox-id]/index.tsx | 0 .../chat/[sandbox-id]/rename-conversation.tsx | 0 .../(1_kiloclaw)}/chat/instance-picker.tsx | 5 ++-- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 16 ++-------- apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 12 ++++++-- apps/mobile/src/app/(app)/_layout.tsx | 11 ------- .../src/components/home/kiloclaw-card.tsx | 5 ++-- .../chat-sandbox-layout-subscription.test.ts | 2 +- .../kilo-chat/conversation-list-screen.tsx | 7 +++-- .../components/kilo-chat/conversation-row.tsx | 5 ++-- .../kilo-chat/conversation-screen.tsx | 13 ++++++--- .../kiloclaw/instance-entry-state.test.ts | 7 ++--- .../kiloclaw/instance-entry-state.ts | 10 +------ .../components/kiloclaw/onboarding-flow.tsx | 3 +- apps/mobile/src/lib/app-stack-routes.ts | 1 - apps/mobile/src/lib/kilo-chat-routes.ts | 29 +++++++++++++++++++ apps/mobile/src/lib/notification-path.test.ts | 8 ++--- apps/mobile/src/lib/notification-path.ts | 6 ++-- 21 files changed, 98 insertions(+), 65 deletions(-) rename apps/mobile/src/app/(app)/{ => (tabs)/(1_kiloclaw)}/chat/[sandbox-id]/[conversation-id].tsx (91%) rename apps/mobile/src/app/(app)/{ => (tabs)/(1_kiloclaw)}/chat/[sandbox-id]/_layout.tsx (100%) rename apps/mobile/src/app/(app)/{ => (tabs)/(1_kiloclaw)}/chat/[sandbox-id]/index.tsx (100%) rename apps/mobile/src/app/(app)/{ => (tabs)/(1_kiloclaw)}/chat/[sandbox-id]/rename-conversation.tsx (100%) rename apps/mobile/src/app/(app)/{ => (tabs)/(1_kiloclaw)}/chat/instance-picker.tsx (95%) delete mode 100644 apps/mobile/src/lib/app-stack-routes.ts create mode 100644 apps/mobile/src/lib/kilo-chat-routes.ts diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index 0ed7086536..fbef810db1 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -1,9 +1,25 @@ import { Stack } from 'expo-router'; +import { CHAT_STACK_ROUTE_NAME } from '@/lib/kilo-chat-routes'; + export const unstable_settings = { initialRouteName: 'index', }; export default function KiloClawLayout() { - return ; + return ( + + + + + + ); } diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx similarity index 91% rename from apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx index 8928ddc8f4..44afa1a9de 100644 --- a/apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx @@ -1,4 +1,4 @@ -import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect } from 'react'; import { toast } from 'sonner-native'; @@ -10,6 +10,7 @@ import { } from '@/components/kilo-chat/conversation-route-state'; import { useConversationDetail } from '@/components/kilo-chat/hooks/use-conversations'; import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function ChatConversationRoute() { const params = useLocalSearchParams<{ 'sandbox-id': string; 'conversation-id': string }>(); @@ -18,7 +19,7 @@ export default function ChatConversationRoute() { const router = useRouter(); const client = useKiloChatClient(); const conversationDetail = useConversationDetail(client, conversationId); - const redirectPath = `/(app)/chat/${sandboxId}` as Href; + const redirectPath = chatSandboxPath(sandboxId); const routeDecision = getConversationRouteDecision({ detail: conversationDetail, routeSandboxId: sandboxId, diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx similarity index 100% rename from apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx similarity index 100% rename from apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx diff --git a/apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/rename-conversation.tsx similarity index 100% rename from apps/mobile/src/app/(app)/chat/[sandbox-id]/rename-conversation.tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/rename-conversation.tsx diff --git a/apps/mobile/src/app/(app)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx similarity index 95% rename from apps/mobile/src/app/(app)/chat/instance-picker.tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx index a6af7f15bc..457fb538e2 100644 --- a/apps/mobile/src/app/(app)/chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { Check } from 'lucide-react-native'; import { Pressable, ScrollView, View } from 'react-native'; @@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function InstancePickerScreen() { const router = useRouter(); @@ -24,7 +25,7 @@ export default function InstancePickerScreen() { return; } router.dismissAll(); - router.push(`/(app)/chat/${sandboxId}` as Href); + router.push(chatSandboxPath(sandboxId)); }; return ( diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index 9ad68fdde5..5f9bab2256 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -14,6 +14,7 @@ import { useForegroundInvalidateKiloclawState } from '@/lib/hooks/use-foreground import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; export default function KiloClawTab() { @@ -33,18 +34,7 @@ export default function KiloClawTab() { })(); }, []); - const redirectSandboxId = entryDecision.kind === 'redirect' ? entryDecision.sandboxId : null; - - useEffect(() => { - if (redirectSandboxId !== null) { - router.replace(`/(app)/chat/${redirectSandboxId}` as Href); - } - }, [redirectSandboxId, router]); - - const showInstanceSkeleton = - entryDecision.kind === 'loading' || - entryDecision.kind === 'redirect' || - onboardingQuery.isPending; + const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending; if (instancesQuery.isError) { return ( @@ -77,7 +67,7 @@ export default function KiloClawTab() { void instancesQuery.refetch(); }} onSelect={sandboxId => { - router.push(`/(app)/chat/${sandboxId}` as Href); + router.push(chatSandboxPath(sandboxId)); }} onCreate={() => { router.push('/(app)/onboarding' as Href); diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index d3ac833371..b8870de364 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { Tabs } from 'expo-router'; +import { type Href, Tabs, usePathname, useRouter } from 'expo-router'; import { Bot, House, MessageSquare } from 'lucide-react-native'; import { Platform, type TextStyle, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -37,8 +37,13 @@ function TabBarBackground() { } export default function TabsLayout() { + const router = useRouter(); + const pathname = usePathname(); const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); + const pathParts = pathname.split('/').filter(Boolean); + const hideTabs = + pathParts[0] === 'chat' && pathParts.length === 3 && pathParts[2] !== 'rename-conversation'; return ( { + tabPress: event => { void Haptics.selectionAsync(); + event.preventDefault(); + router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); }, }} /> diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 3f66e55643..d7dbc544eb 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -2,7 +2,6 @@ import { Stack } from 'expo-router'; import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount'; import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider'; -import { CHAT_STACK_ROUTE_NAME } from '@/lib/app-stack-routes'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; export default function AppLayout() { @@ -20,16 +19,6 @@ export default function AppLayout() { }} > - - { - router.push(`/(app)/chat/${instance.sandboxId}` as Href); + router.push(chatSandboxPath(instance.sandboxId)); }; return ( diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts index 59effc5fea..61b60539c0 100644 --- a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts @@ -2,7 +2,7 @@ import type * as ReactModule from 'react'; import { kiloclawInstanceContext } from '@kilocode/event-service'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatSandboxInstanceEventSubscriptionMount } from '../../app/(app)/chat/[sandbox-id]/_layout'; +import { ChatSandboxInstanceEventSubscriptionMount } from '../../app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout'; type TestState = { cleanupCalls: number; diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index e5699ba098..b0ff907c2c 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -1,5 +1,5 @@ import { FlashList } from '@shopify/flash-list'; -import { type Href, useRouter } from 'expo-router'; +import { useRouter } from 'expo-router'; import { useCallback } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, View } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; @@ -9,6 +9,7 @@ import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatConversationPath } from '@/lib/kilo-chat-routes'; import { Plus } from 'lucide-react-native'; import { EmptyConversationList } from './empty-conversation-list'; @@ -88,7 +89,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { useInstancePresence(sandboxId); function handleRowPress(conversationId: string) { - router.push(`/(app)/chat/${sandboxId}/${conversationId}` as Href); + router.push(chatConversationPath(sandboxId, conversationId)); } function handleCreateAndNavigate() { @@ -96,7 +97,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { { sandboxId }, { onSuccess: result => { - router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as Href); + router.push(chatConversationPath(sandboxId, result.conversationId)); }, } ); diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx index dc230ed933..c8d786e651 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-row.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -1,13 +1,14 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { CONVERSATION_TITLE_MAX_CHARS, type ConversationListItem } from '@kilocode/kilo-chat'; import * as Haptics from 'expo-haptics'; -import { type Href, useRouter } from 'expo-router'; +import { useRouter } from 'expo-router'; import { MoreVertical } from 'lucide-react-native'; import { Alert, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatRenameConversationPath } from '@/lib/kilo-chat-routes'; import { timeAgo } from '@/lib/utils'; type ConversationRowProps = { @@ -49,7 +50,7 @@ export function ConversationRow({ conversationId: conversation.conversationId, title: title.slice(0, CONVERSATION_TITLE_MAX_CHARS), }); - router.push(`/(app)/chat/${sandboxId}/rename-conversation?${params.toString()}` as Href); + router.push(chatRenameConversationPath(sandboxId, params)); } function confirmLeave() { diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index b7dc99fba7..01830f9c24 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -27,7 +27,7 @@ import { } from '@kilocode/kilo-chat'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, KeyboardAvoidingView, Platform, View } from 'react-native'; -import { type Href, useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -61,6 +61,11 @@ import { useCurrentUserId } from './hooks/use-current-user-id'; import { TypingIndicator } from './typing-indicator'; import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; +import { + chatInstancePickerPath, + chatRenameConversationPath, + chatSandboxPath, +} from '@/lib/kilo-chat-routes'; import { setActiveChatLocation } from '@/lib/notifications'; type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; @@ -151,7 +156,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const instanceLabel = currentInstance?.name ?? currentInstance?.organizationName ?? 'KiloClaw'; const handleSwitchInstance = useCallback(() => { - router.push(`/(app)/chat/instance-picker?currentId=${sandboxId}` as Href); + router.push(chatInstancePickerPath(sandboxId)); }, [router, sandboxId]); const handleOpenConversationOptions = useCallback(() => { @@ -167,7 +172,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl index => { if (index === 0) { const params = new URLSearchParams({ conversationId, title: conversationTitle }); - router.push(`/(app)/chat/${sandboxId}/rename-conversation?${params.toString()}` as Href); + router.push(chatRenameConversationPath(sandboxId, params)); return; } if (index === 1) { @@ -182,7 +187,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl { conversationId, sandboxId }, { onSuccess: () => { - router.replace(`/(app)/chat/${sandboxId}` as Href); + router.replace(chatSandboxPath(sandboxId)); }, } ); diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts index 16a3d3e940..9f58e2eea9 100644 --- a/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.test.ts @@ -14,11 +14,8 @@ describe('getKiloClawEntryDecision', () => { expect(getKiloClawEntryDecision([])).toEqual({ kind: 'empty' }); }); - it('redirects directly when exactly one instance exists', () => { - expect(getKiloClawEntryDecision([personal])).toEqual({ - kind: 'redirect', - sandboxId: 'personal-1', - }); + it('shows the picker when exactly one instance exists', () => { + expect(getKiloClawEntryDecision([personal])).toEqual({ kind: 'list' }); }); it('shows the picker when multiple instances exist', () => { diff --git a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts index 113403d61e..40719e17ee 100644 --- a/apps/mobile/src/components/kiloclaw/instance-entry-state.ts +++ b/apps/mobile/src/components/kiloclaw/instance-entry-state.ts @@ -2,11 +2,7 @@ type InstanceLike = { sandboxId: string; }; -type KiloClawEntryDecision = - | { kind: 'loading' } - | { kind: 'empty' } - | { kind: 'redirect'; sandboxId: string } - | { kind: 'list' }; +type KiloClawEntryDecision = { kind: 'loading' } | { kind: 'empty' } | { kind: 'list' }; export function getKiloClawEntryDecision( instances: readonly InstanceLike[] | undefined @@ -17,9 +13,5 @@ export function getKiloClawEntryDecision( if (instances.length === 0) { return { kind: 'empty' }; } - const first = instances[0]; - if (instances.length === 1 && first !== undefined) { - return { kind: 'redirect', sandboxId: first.sandboxId }; - } return { kind: 'list' }; } diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index cb56a270f1..94bceb0aa8 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -43,6 +43,7 @@ import { useKiloClawStatus, } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { chatSandboxPath } from '@/lib/kilo-chat-routes'; import { useTRPC } from '@/lib/trpc'; function categorizeProvisionError(error: { @@ -314,7 +315,7 @@ export function OnboardingFlow() { // closes. router.back(); if (state.sandboxId) { - router.push(`/(app)/chat/${state.sandboxId}` as Href); + router.push(chatSandboxPath(state.sandboxId)); } }, [router, state.sandboxId]); diff --git a/apps/mobile/src/lib/app-stack-routes.ts b/apps/mobile/src/lib/app-stack-routes.ts deleted file mode 100644 index 9eb78bfcbd..0000000000 --- a/apps/mobile/src/lib/app-stack-routes.ts +++ /dev/null @@ -1 +0,0 @@ -export const CHAT_STACK_ROUTE_NAME = 'chat/[sandbox-id]'; diff --git a/apps/mobile/src/lib/kilo-chat-routes.ts b/apps/mobile/src/lib/kilo-chat-routes.ts new file mode 100644 index 0000000000..8b1f6811e8 --- /dev/null +++ b/apps/mobile/src/lib/kilo-chat-routes.ts @@ -0,0 +1,29 @@ +import { type Href } from 'expo-router'; + +const KILOCLAW_TAB_CHAT_ROOT = '/(app)/(tabs)/(1_kiloclaw)/chat'; + +export const CHAT_STACK_ROUTE_NAME = 'chat/[sandbox-id]'; + +export function chatSandboxRoute(sandboxId: string): string { + return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}`; +} + +export function chatConversationRoute(sandboxId: string, conversationId: string): string { + return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}/${conversationId}`; +} + +export function chatSandboxPath(sandboxId: string): Href { + return chatSandboxRoute(sandboxId) as Href; +} + +export function chatConversationPath(sandboxId: string, conversationId: string): Href { + return chatConversationRoute(sandboxId, conversationId) as Href; +} + +export function chatRenameConversationPath(sandboxId: string, params: URLSearchParams): Href { + return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}/rename-conversation?${params.toString()}` as Href; +} + +export function chatInstancePickerPath(currentId: string): Href { + return `${KILOCLAW_TAB_CHAT_ROOT}/instance-picker?currentId=${currentId}` as Href; +} diff --git a/apps/mobile/src/lib/notification-path.test.ts b/apps/mobile/src/lib/notification-path.test.ts index ff235ac8ea..860da78662 100644 --- a/apps/mobile/src/lib/notification-path.test.ts +++ b/apps/mobile/src/lib/notification-path.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { pushDataSchema } from '@kilocode/notifications'; -import { CHAT_STACK_ROUTE_NAME } from './app-stack-routes'; +import { CHAT_STACK_ROUTE_NAME } from './kilo-chat-routes'; import { notificationPathForData } from './notification-path'; describe('notificationPathForData', () => { @@ -13,7 +13,7 @@ describe('notificationPathForData', () => { conversationId: 'conversation-1', messageId: 'message-1', }) - ).toBe('/(app)/chat/sandbox-1/conversation-1'); + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/sandbox-1/conversation-1'); }); it('keeps the registered stack route on the sandbox-id chat segment', () => { @@ -35,7 +35,7 @@ describe('notificationPathForData', () => { event: 'ready', sandboxId: 'abcDEF123_-', }) - ).toBe('/(app)/chat/abcDEF123_-'); + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/abcDEF123_-'); }); it('routes start_failed lifecycle notifications with ki sandbox IDs to the sandbox chat screen', () => { @@ -45,7 +45,7 @@ describe('notificationPathForData', () => { event: 'start_failed', sandboxId: 'ki_deadbeef', }) - ).toBe('/(app)/chat/ki_deadbeef'); + ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/ki_deadbeef'); }); }); diff --git a/apps/mobile/src/lib/notification-path.ts b/apps/mobile/src/lib/notification-path.ts index 6b6e7f406a..90010a2dff 100644 --- a/apps/mobile/src/lib/notification-path.ts +++ b/apps/mobile/src/lib/notification-path.ts @@ -1,8 +1,10 @@ import { type PushData } from '@kilocode/notifications'; +import { chatConversationRoute, chatSandboxRoute } from './kilo-chat-routes'; + export function notificationPathForData(data: PushData): string { if (data.type === 'chat.message') { - return `/(app)/chat/${data.sandboxId}/${data.conversationId}`; + return chatConversationRoute(data.sandboxId, data.conversationId); } - return `/(app)/chat/${data.sandboxId}`; + return chatSandboxRoute(data.sandboxId); } From a4fb74aa97e7af54047c7c517212757a1616257c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:38:51 +0200 Subject: [PATCH 249/289] fix(mobile): align kiloclaw tab header --- .../mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 10 +++++++++- .../src/components/kiloclaw/instance-list-screen.tsx | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index 5f9bab2256..369520dc1e 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -41,7 +41,9 @@ export default function KiloClawTab() { } /> @@ -78,7 +80,13 @@ export default function KiloClawTab() { return ( - } /> + } + /> {showInstanceSkeleton ? ( diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 4b889bd2c1..0cf0f98f18 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -83,7 +83,13 @@ export function InstanceListScreen({ return ( - } /> + } + /> Date: Mon, 4 May 2026 16:43:12 +0200 Subject: [PATCH 250/289] fix(mobile): hide create instance with instances --- .../kiloclaw/instance-list-screen.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 0cf0f98f18..4fb8057585 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -101,17 +101,19 @@ export function InstanceListScreen({ Choose an instance to view conversations. - + {instances.length === 0 ? ( + + ) : null} Date: Mon, 4 May 2026 16:43:40 +0200 Subject: [PATCH 251/289] fix(mobile): remove invalid kiloclaw route registration --- apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx | 3 --- apps/mobile/src/lib/kilo-chat-routes.ts | 2 -- apps/mobile/src/lib/notification-path.test.ts | 6 ++---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index fbef810db1..8b08037015 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -1,7 +1,5 @@ import { Stack } from 'expo-router'; -import { CHAT_STACK_ROUTE_NAME } from '@/lib/kilo-chat-routes'; - export const unstable_settings = { initialRouteName: 'index', }; @@ -10,7 +8,6 @@ export default function KiloClawLayout() { return ( - { @@ -16,8 +15,7 @@ describe('notificationPathForData', () => { ).toBe('/(app)/(tabs)/(1_kiloclaw)/chat/sandbox-1/conversation-1'); }); - it('keeps the registered stack route on the sandbox-id chat segment', () => { - expect(CHAT_STACK_ROUTE_NAME).toBe('chat/[sandbox-id]'); + it('keeps notifications on the tab-owned KiloClaw chat route', () => { expect( notificationPathForData({ type: 'chat.message', @@ -25,7 +23,7 @@ describe('notificationPathForData', () => { conversationId: 'conversation-1', messageId: 'message-1', }) - ).toContain('/chat/sandbox-1/'); + ).toContain('/(app)/(tabs)/(1_kiloclaw)/chat/sandbox-1/'); }); it('routes ready lifecycle notifications with legacy sandbox IDs to the sandbox chat screen', () => { From 5b3670773df7d69d4d7b2b51019b03acdb334928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:44:26 +0200 Subject: [PATCH 252/289] fix(mobile): keep home tab navigation intact --- apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index b8870de364..e9f94e0165 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -79,10 +79,8 @@ export default function TabsLayout() { ), }} listeners={{ - tabPress: event => { + tabPress: () => { void Haptics.selectionAsync(); - event.preventDefault(); - router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); }, }} /> @@ -96,8 +94,10 @@ export default function TabsLayout() { ), }} listeners={{ - tabPress: () => { + tabPress: event => { void Haptics.selectionAsync(); + event.preventDefault(); + router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); }, }} /> From b594ca0d77307d101179fc1bdb19d202128257d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:45:37 +0200 Subject: [PATCH 253/289] fix(mobile): remove kiloclaw instance subtitle --- .../src/components/kiloclaw/instance-list-screen.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 4fb8057585..cff07791c1 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -97,11 +97,8 @@ export function InstanceListScreen({ showsVerticalScrollIndicator={false} refreshControl={} > - - - Choose an instance to view conversations. - - {instances.length === 0 ? ( + {instances.length === 0 ? ( + - ) : null} - + + ) : null} Date: Mon, 4 May 2026 16:49:43 +0200 Subject: [PATCH 254/289] fix(mobile): hide kiloclaw section counts --- .../components/kiloclaw/instance-list-screen.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index cff07791c1..cc6c35e1df 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -19,6 +19,7 @@ type Props = { onCreate: () => void; refreshing: boolean; onRefresh: () => void; + showSectionCounts?: boolean; }; function splitInstances(instances: ClawInstance[]) { @@ -33,11 +34,13 @@ function InstanceSection({ instances, currentSandboxId, onSelect, + showCount, }: Readonly<{ title: string; instances: ClawInstance[]; currentSandboxId: string | null; onSelect: (sandboxId: string) => void; + showCount: boolean; }>) { if (instances.length === 0) { return null; @@ -47,9 +50,11 @@ function InstanceSection({ {title} - - {instances.length} - + {showCount ? ( + + {instances.length} + + ) : null} {instances.map(instance => ( @@ -72,6 +77,7 @@ export function InstanceListScreen({ onCreate, refreshing, onRefresh, + showSectionCounts = false, }: Readonly) { const colors = useThemeColors(); const { personal, organizations } = splitInstances(instances); @@ -118,12 +124,14 @@ export function InstanceListScreen({ instances={personal} currentSandboxId={currentSandboxId} onSelect={handleSelect} + showCount={showSectionCounts} /> From 50622036f1119d98105550e3dddb51aa422b9471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:50:06 +0200 Subject: [PATCH 255/289] fix(mobile): remove kiloclaw current marker --- .../src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 11 ----------- .../src/components/kiloclaw/instance-list-row.tsx | 6 ++---- .../components/kiloclaw/instance-list-screen.tsx | 13 +------------ 3 files changed, 3 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index 369520dc1e..e5c9c59309 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -1,5 +1,4 @@ import { type Href, useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -15,7 +14,6 @@ import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { chatSandboxPath } from '@/lib/kilo-chat-routes'; -import { getLastActiveInstance, loadLastActiveInstance } from '@/lib/last-active-instance'; export default function KiloClawTab() { const router = useRouter(); @@ -24,16 +22,8 @@ export default function KiloClawTab() { const { data: instances } = instancesQuery; const entryDecision = getKiloClawEntryDecision(instances); const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); - const [currentSandboxId, setCurrentSandboxId] = useState(null); useForegroundInvalidateKiloclawState(); - useEffect(() => { - void (async () => { - await loadLastActiveInstance(); - setCurrentSandboxId(getLastActiveInstance()); - })(); - }, []); - const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending; if (instancesQuery.isError) { @@ -63,7 +53,6 @@ export default function KiloClawTab() { return ( { void instancesQuery.refetch(); diff --git a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx index cee2555358..7bd6e5d50d 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx @@ -1,4 +1,4 @@ -import { Building2, CheckCircle2, ChevronRight, UserRound } from 'lucide-react-native'; +import { Building2, ChevronRight, UserRound } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; @@ -8,7 +8,6 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type InstanceListRowProps = { instance: ClawInstance; - isCurrent: boolean; onPress: (sandboxId: string) => void; }; @@ -20,7 +19,7 @@ function instanceSubtitle(instance: ClawInstance): string { return instance.organizationName ?? 'Personal'; } -export function InstanceListRow({ instance, isCurrent, onPress }: Readonly) { +export function InstanceListRow({ instance, onPress }: Readonly) { const colors = useThemeColors(); const Icon = instance.organizationName ? Building2 : UserRound; @@ -44,7 +43,6 @@ export function InstanceListRow({ instance, isCurrent, onPress }: Readonly {instanceTitle(instance)} - {isCurrent ? : null} diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index cc6c35e1df..894cc2bcda 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -14,7 +14,6 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type Props = { instances: ClawInstance[]; - currentSandboxId: string | null; onSelect: (sandboxId: string) => void; onCreate: () => void; refreshing: boolean; @@ -32,13 +31,11 @@ function splitInstances(instances: ClawInstance[]) { function InstanceSection({ title, instances, - currentSandboxId, onSelect, showCount, }: Readonly<{ title: string; instances: ClawInstance[]; - currentSandboxId: string | null; onSelect: (sandboxId: string) => void; showCount: boolean; }>) { @@ -58,12 +55,7 @@ function InstanceSection({ {instances.map(instance => ( - + ))} @@ -72,7 +64,6 @@ function InstanceSection({ export function InstanceListScreen({ instances, - currentSandboxId, onSelect, onCreate, refreshing, @@ -122,14 +113,12 @@ export function InstanceListScreen({ From 075dce16cf3891e45991c9b89178f77a42e53793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:50:53 +0200 Subject: [PATCH 256/289] fix(mobile): show agent identity in kiloclaw rows --- .../components/kiloclaw/instance-list-row.tsx | 22 ++++++++++++++----- apps/web/src/routers/kiloclaw-router.ts | 6 +++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx index 7bd6e5d50d..9e592c445c 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx @@ -1,8 +1,9 @@ -import { Building2, ChevronRight, UserRound } from 'lucide-react-native'; +import { ChevronRight } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; import { StatusBadge } from '@/components/kiloclaw/status-badge'; import { Text } from '@/components/ui/text'; +import { agentColor } from '@/lib/agent-color'; import { type ClawInstance } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -12,7 +13,7 @@ type InstanceListRowProps = { }; function instanceTitle(instance: ClawInstance): string { - return instance.name ?? 'KiloClaw instance'; + return instance.botName ?? instance.name ?? 'KiloClaw'; } function instanceSubtitle(instance: ClawInstance): string { @@ -21,7 +22,8 @@ function instanceSubtitle(instance: ClawInstance): string { export function InstanceListRow({ instance, onPress }: Readonly) { const colors = useThemeColors(); - const Icon = instance.organizationName ? Building2 : UserRound; + const title = instanceTitle(instance); + const hue = agentColor(title); return ( - - + + {instance.botEmoji ? ( + {instance.botEmoji} + ) : ( + + {title.trim()[0]?.toUpperCase() ?? 'K'} + + )} @@ -41,7 +51,7 @@ export function InstanceListRow({ instance, onPress }: Readonly - {instanceTitle(instance)} + {title} diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index bf8c03987b..881faab02d 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -2321,9 +2321,13 @@ export const kiloclawRouter = createTRPCRouter({ const results = await Promise.all( instances.map(async instance => { let status: string | null = null; + let botName: string | null = null; + let botEmoji: string | null = null; try { const workerStatus = await client.getStatus(ctx.user.id, workerInstanceId(instance)); status = workerStatus.status; + botName = workerStatus.botName; + botEmoji = workerStatus.botEmoji; } catch { // Worker unreachable — show as null (unknown) } @@ -2335,6 +2339,8 @@ export const kiloclawRouter = createTRPCRouter({ organizationName: instance.organizationId ? (orgNameMap.get(instance.organizationId) ?? null) : null, + botName, + botEmoji, status, }; }) From 52e38d4523db83b9fc8f861f6e84e1dc8ffa34c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 16:57:21 +0200 Subject: [PATCH 257/289] fix(mobile): make kiloclaw conversation list route match --- .../app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx | 9 +++++++++ .../chat/[sandbox-id]/[conversation-id].tsx | 14 +++++++++----- .../(1_kiloclaw)/chat/[sandbox-id]/index.tsx | 8 +++++++- .../chat-sandbox-layout-subscription.test.ts | 3 +-- .../kilo-chat/chat-sandbox-route-mounts.tsx} | 17 ++--------------- 5 files changed, 28 insertions(+), 23 deletions(-) rename apps/mobile/src/{app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx => components/kilo-chat/chat-sandbox-route-mounts.tsx} (61%) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index 8b08037015..64d63d7df5 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -8,6 +8,15 @@ export default function KiloClawLayout() { return ( + + <> + + + ); } diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx index 347876e7e2..dff5ceb55c 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx @@ -1,5 +1,6 @@ import { useLocalSearchParams } from 'expo-router'; +import { ChatSandboxRouteMounts } from '@/components/kilo-chat/chat-sandbox-route-mounts'; import { ConversationListScreen } from '@/components/kilo-chat/conversation-list-screen'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; @@ -8,5 +9,10 @@ export default function ChatSandboxIndex() { const { data: instances } = useAllKiloClawInstances(); const instance = instances?.find(i => i.sandboxId === sandboxId); const sandboxLabel = instance?.name ?? instance?.organizationName ?? 'Chat'; - return ; + return ( + <> + + + + ); } diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts index 61b60539c0..094673c5d6 100644 --- a/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-layout-subscription.test.ts @@ -2,7 +2,7 @@ import type * as ReactModule from 'react'; import { kiloclawInstanceContext } from '@kilocode/event-service'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatSandboxInstanceEventSubscriptionMount } from '../../app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout'; +import { ChatSandboxInstanceEventSubscriptionMount } from './chat-sandbox-route-mounts'; type TestState = { cleanupCalls: number; @@ -44,7 +44,6 @@ vi.mock('react', async () => { }); vi.mock('expo-router', () => ({ - Stack: () => null, useFocusEffect: (effect: ReactModule.EffectCallback) => { const cleanup = effect(); if (typeof cleanup === 'function') { diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx similarity index 61% rename from apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx rename to apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx index 9b8a7843d8..b8d9989f85 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/_layout.tsx +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useFocusEffect, useLocalSearchParams } from 'expo-router'; import { useCallback } from 'react'; import { useInstanceEventSubscription } from '@/components/kilo-chat/hooks/use-instance-event-subscription'; @@ -24,24 +24,11 @@ export function ChatSandboxLastActiveInstanceMount() { return null; } -export default function ChatSandboxLayout() { +export function ChatSandboxRouteMounts() { return ( <> - - - - - ); } From f4043c27dc37f8628178f1466f031bf0ac01454b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 17:35:28 +0200 Subject: [PATCH 258/289] fix(mobile): polish kilo chat conversation list --- .../(1_kiloclaw)/chat/[sandbox-id]/index.tsx | 3 +- .../kilo-chat/chat-sandbox-route-mounts.tsx | 2 +- .../kilo-chat/conversation-list-screen.tsx | 35 ++++++++++++------- .../components/kilo-chat/conversation-row.tsx | 18 ++++++---- .../kilo-chat/empty-conversation-list.tsx | 8 ++--- 5 files changed, 41 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx index dff5ceb55c..aa120b9156 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/index.tsx @@ -8,7 +8,8 @@ export default function ChatSandboxIndex() { const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); const { data: instances } = useAllKiloClawInstances(); const instance = instances?.find(i => i.sandboxId === sandboxId); - const sandboxLabel = instance?.name ?? instance?.organizationName ?? 'Chat'; + const sandboxLabel = + instance?.botName ?? instance?.name ?? instance?.organizationName ?? 'KiloClaw'; return ( <> diff --git a/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx index b8d9989f85..fe26993857 100644 --- a/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx +++ b/apps/mobile/src/components/kilo-chat/chat-sandbox-route-mounts.tsx @@ -10,7 +10,7 @@ export function ChatSandboxInstanceEventSubscriptionMount() { return null; } -export function ChatSandboxLastActiveInstanceMount() { +function ChatSandboxLastActiveInstanceMount() { const { 'sandbox-id': sandboxId } = useLocalSearchParams<{ 'sandbox-id': string }>(); useFocusEffect( diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index b0ff907c2c..349f88e952 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -1,4 +1,5 @@ import { FlashList } from '@shopify/flash-list'; +import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import { useCallback } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, View } from 'react-native'; @@ -44,15 +45,19 @@ type ConversationListEntry = ConversationHeaderItem | ConversationItem; function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) { return ( - - {showHeader ? : null} + + {showHeader ? : null} {[0, 1, 2, 3].map(i => ( - - + + + - + ))} @@ -89,10 +94,12 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { useInstancePresence(sandboxId); function handleRowPress(conversationId: string) { + void Haptics.selectionAsync(); router.push(chatConversationPath(sandboxId, conversationId)); } function handleCreateAndNavigate() { + void Haptics.selectionAsync(); createConversation.mutate( { sandboxId }, { @@ -121,8 +128,8 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { if (contentState === 'loading') { return ( - - + + @@ -132,8 +139,8 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { if (contentState === 'error') { return ( - - + + + item.kind === 'header' ? ( - + {item.label} ) : ( - + + ) : null diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx index c8d786e651..6627667588 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-row.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -2,7 +2,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { CONVERSATION_TITLE_MAX_CHARS, type ConversationListItem } from '@kilocode/kilo-chat'; import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { MoreVertical } from 'lucide-react-native'; +import { MessageSquare, MoreVertical } from 'lucide-react-native'; import { Alert, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -91,12 +91,16 @@ export function ConversationRow({ { onPress(conversation.conversationId); }} > - + + + + {title} + + {hasUnread(conversation) ? ( ) : null} + + {timeAgo(new Date(conversationTimestamp(conversation)))} + - - {timeAgo(new Date(conversationTimestamp(conversation)))} - + - {isStarting ? 'Starting…' : 'Start a conversation'} + } /> From f50d05c83de61248a2b6a8c6d80a337510c675f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 17:49:22 +0200 Subject: [PATCH 259/289] fix(mobile): add full-page pull refresh --- .../kiloclaw/[instance-id]/dashboard.tsx | 59 ++++++++++++++++++- .../kilo-chat/conversation-list-screen.tsx | 21 +++++-- .../kiloclaw/instance-list-screen.tsx | 2 +- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx index 025d16f0ed..5bd70c0011 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -1,7 +1,15 @@ import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; import { CreditCard, Newspaper, Pencil } from 'lucide-react-native'; -import { useState } from 'react'; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from 'react-native'; +import { useCallback, useState } from 'react'; +import { + Alert, + Linking, + Platform, + Pressable, + RefreshControl, + ScrollView, + View, +} from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { BillingBanner } from '@/components/kiloclaw/billing-banner'; @@ -55,6 +63,39 @@ export default function DashboardScreen() { const isLoading = statusQuery.isPending || (isPersonal && billingQuery.isPending); const [renameVisible, setRenameVisible] = useState(false); + const refetchStatus = statusQuery.refetch; + const refetchBilling = billingQuery.refetch; + const refetchServiceDegraded = serviceDegradedQuery.refetch; + const refetchGateway = gatewayQuery.refetch; + const refetchConfig = configQuery.refetch; + + const refreshing = + statusQuery.isRefetching || + configQuery.isRefetching || + serviceDegradedQuery.isRefetching || + (isRunning && gatewayQuery.isRefetching) || + (isPersonal && billingQuery.isRefetching); + + const handleRefresh = useCallback(() => { + void (async () => { + const refreshes = [ + refetchStatus(), + refetchConfig(), + refetchServiceDegraded(), + ...(isRunning ? [refetchGateway()] : []), + ...(isPersonal ? [refetchBilling()] : []), + ]; + await Promise.all(refreshes); + })(); + }, [ + refetchBilling, + refetchConfig, + refetchGateway, + refetchServiceDegraded, + refetchStatus, + isPersonal, + isRunning, + ]); if (isLoading) { return ( @@ -126,7 +167,19 @@ export default function DashboardScreen() { } /> - + + } + > ) { return ( @@ -90,6 +93,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const hasNextPage = listQuery.hasNextPage; const isFetchingNextPage = listQuery.isFetchingNextPage; const fetchNextPage = listQuery.fetchNextPage; + const refetchConversations = listQuery.refetch; useInstancePresence(sandboxId); @@ -120,6 +124,10 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { } }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + const handleRefresh = useCallback(() => { + void refetchConversations(); + }, [refetchConversations]); + const contentState = getConversationListContentState({ isPending: listQuery.isPending, isError: listQuery.isError, @@ -156,6 +164,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const conversations = listQuery.data?.conversations ?? []; const entries = flattenConversationGroups(conversations, now); + const refreshing = listQuery.isRefetching && !listQuery.isFetchingNextPage; return ( @@ -181,6 +190,8 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { /> entry.kind === 'header' ? `header:${entry.label}` : entry.conversation.conversationId @@ -218,10 +229,10 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { onEndReachedThreshold={0.5} refreshControl={ { - void listQuery.refetch(); - }} + refreshing={refreshing} + onRefresh={handleRefresh} + colors={[colors.mutedForeground]} + tintColor={colors.mutedForeground} /> } /> diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 894cc2bcda..6395ffa674 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -90,7 +90,7 @@ export function InstanceListScreen({ } > From a0902cfce30f5a169781c4c1da8ccb35e103faf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 17:51:56 +0200 Subject: [PATCH 260/289] fix(mobile): hide refresh spinner during polling --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 20 +++++++++--- .../kiloclaw/[instance-id]/dashboard.tsx | 31 +++++++++---------- .../kilo-chat/conversation-list-screen.tsx | 15 ++++++--- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index e5c9c59309..c0932542dc 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -1,4 +1,5 @@ import { type Href, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; import { View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -18,14 +19,27 @@ import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function KiloClawTab() { const router = useRouter(); const colors = useThemeColors(); + const [manualRefreshing, setManualRefreshing] = useState(false); const instancesQuery = useAllKiloClawInstances(); const { data: instances } = instancesQuery; + const refetchInstances = instancesQuery.refetch; const entryDecision = getKiloClawEntryDecision(instances); const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); useForegroundInvalidateKiloclawState(); const showInstanceSkeleton = entryDecision.kind === 'loading' || onboardingQuery.isPending; + const handleRefresh = useCallback(() => { + void (async () => { + setManualRefreshing(true); + try { + await refetchInstances(); + } finally { + setManualRefreshing(false); + } + })(); + }, [refetchInstances]); + if (instancesQuery.isError) { return ( @@ -53,10 +67,8 @@ export default function KiloClawTab() { return ( { - void instancesQuery.refetch(); - }} + refreshing={manualRefreshing} + onRefresh={handleRefresh} onSelect={sandboxId => { router.push(chatSandboxPath(sandboxId)); }} diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx index 5bd70c0011..4572f8b2eb 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -63,29 +63,28 @@ export default function DashboardScreen() { const isLoading = statusQuery.isPending || (isPersonal && billingQuery.isPending); const [renameVisible, setRenameVisible] = useState(false); + const [manualRefreshing, setManualRefreshing] = useState(false); const refetchStatus = statusQuery.refetch; const refetchBilling = billingQuery.refetch; const refetchServiceDegraded = serviceDegradedQuery.refetch; const refetchGateway = gatewayQuery.refetch; const refetchConfig = configQuery.refetch; - const refreshing = - statusQuery.isRefetching || - configQuery.isRefetching || - serviceDegradedQuery.isRefetching || - (isRunning && gatewayQuery.isRefetching) || - (isPersonal && billingQuery.isRefetching); - const handleRefresh = useCallback(() => { void (async () => { - const refreshes = [ - refetchStatus(), - refetchConfig(), - refetchServiceDegraded(), - ...(isRunning ? [refetchGateway()] : []), - ...(isPersonal ? [refetchBilling()] : []), - ]; - await Promise.all(refreshes); + setManualRefreshing(true); + try { + const refreshes = [ + refetchStatus(), + refetchConfig(), + refetchServiceDegraded(), + ...(isRunning ? [refetchGateway()] : []), + ...(isPersonal ? [refetchBilling()] : []), + ]; + await Promise.all(refreshes); + } finally { + setManualRefreshing(false); + } })(); }, [ refetchBilling, @@ -173,7 +172,7 @@ export default function DashboardScreen() { showsVerticalScrollIndicator={false} refreshControl={ { - void refetchConversations(); + void (async () => { + setManualRefreshing(true); + try { + await refetchConversations(); + } finally { + setManualRefreshing(false); + } + })(); }, [refetchConversations]); const contentState = getConversationListContentState({ @@ -164,7 +172,6 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const conversations = listQuery.data?.conversations ?? []; const entries = flattenConversationGroups(conversations, now); - const refreshing = listQuery.isRefetching && !listQuery.isFetchingNextPage; return ( @@ -229,7 +236,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { onEndReachedThreshold={0.5} refreshControl={ Date: Mon, 4 May 2026 18:20:36 +0200 Subject: [PATCH 261/289] fix(mobile): show bot name in chat header --- .../kilo-chat/conversation-screen.tsx | 3 +- apps/mobile/src/lib/kiloclaw-display.test.ts | 35 +++++++++++++++++++ apps/mobile/src/lib/kiloclaw-display.ts | 21 +++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/kiloclaw-display.test.ts create mode 100644 apps/mobile/src/lib/kiloclaw-display.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 01830f9c24..3e43b7cb84 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -61,6 +61,7 @@ import { useCurrentUserId } from './hooks/use-current-user-id'; import { TypingIndicator } from './typing-indicator'; import { useAllKiloClawInstances, useInstanceContext } from '@/lib/hooks/use-instance-context'; import { useKiloClawStatus } from '@/lib/hooks/use-kiloclaw-queries'; +import { kiloclawConversationEyebrow } from '@/lib/kiloclaw-display'; import { chatInstancePickerPath, chatRenameConversationPath, @@ -153,7 +154,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl const { data: instances } = useAllKiloClawInstances(); const currentInstance = instances?.find(instance => instance.sandboxId === sandboxId); const canSwitchInstance = (instances?.length ?? 0) > 1; - const instanceLabel = currentInstance?.name ?? currentInstance?.organizationName ?? 'KiloClaw'; + const instanceLabel = kiloclawConversationEyebrow(currentInstance); const handleSwitchInstance = useCallback(() => { router.push(chatInstancePickerPath(sandboxId)); diff --git a/apps/mobile/src/lib/kiloclaw-display.test.ts b/apps/mobile/src/lib/kiloclaw-display.test.ts new file mode 100644 index 0000000000..3c5dbcc075 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-display.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { kiloclawConversationEyebrow } from './kiloclaw-display'; + +describe('KiloClaw display labels', () => { + it('uses the bot name above a conversation title', () => { + expect( + kiloclawConversationEyebrow({ + botName: 'Helper Bot', + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Helper Bot'); + }); + + it('falls back when the conversation instance has no bot name', () => { + expect( + kiloclawConversationEyebrow({ + botName: null, + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Production instance'); + + expect( + kiloclawConversationEyebrow({ + botName: null, + name: null, + organizationName: 'Engineering', + }) + ).toBe('Engineering'); + + expect(kiloclawConversationEyebrow(undefined)).toBe('KiloClaw'); + }); +}); diff --git a/apps/mobile/src/lib/kiloclaw-display.ts b/apps/mobile/src/lib/kiloclaw-display.ts new file mode 100644 index 0000000000..3a67600b24 --- /dev/null +++ b/apps/mobile/src/lib/kiloclaw-display.ts @@ -0,0 +1,21 @@ +type KiloClawDisplayInstance = { + botName?: string | null; + name?: string | null; + organizationName?: string | null; +}; + +function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return null; +} + +export function kiloclawConversationEyebrow(instance: KiloClawDisplayInstance | undefined) { + return ( + firstDisplayValue([instance?.botName, instance?.name, instance?.organizationName]) ?? 'KiloClaw' + ); +} From 8228d9d1a2d7d73a223ede4d398d41000ad89be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:21:16 +0200 Subject: [PATCH 262/289] fix(mobile): title instance switcher by bot --- .../(1_kiloclaw)/chat/instance-picker.tsx | 3 +- apps/mobile/src/lib/kiloclaw-display.test.ts | 32 ++++++++++++++++++- apps/mobile/src/lib/kiloclaw-display.ts | 7 ++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx index 457fb538e2..ceab1bfc55 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { kiloclawInstanceSwitcherTitle } from '@/lib/kiloclaw-display'; import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function InstancePickerScreen() { @@ -66,7 +67,7 @@ export default function InstancePickerScreen() { {!instancesQuery.isPending && !instancesQuery.isError ? (instances ?? []).map(instance => { const isCurrent = instance.sandboxId === currentId; - const title = instance.name ?? instance.organizationName ?? 'KiloClaw instance'; + const title = kiloclawInstanceSwitcherTitle(instance); return ( { it('uses the bot name above a conversation title', () => { @@ -32,4 +32,34 @@ describe('KiloClaw display labels', () => { expect(kiloclawConversationEyebrow(undefined)).toBe('KiloClaw'); }); + + it('uses the bot name for instance switcher cards', () => { + expect( + kiloclawInstanceSwitcherTitle({ + botName: 'Deploy Bot', + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Deploy Bot'); + }); + + it('falls back when an instance switcher card has no bot name', () => { + expect( + kiloclawInstanceSwitcherTitle({ + botName: null, + name: 'Production instance', + organizationName: 'Engineering', + }) + ).toBe('Production instance'); + + expect( + kiloclawInstanceSwitcherTitle({ + botName: null, + name: null, + organizationName: 'Engineering', + }) + ).toBe('Engineering'); + + expect(kiloclawInstanceSwitcherTitle(undefined)).toBe('KiloClaw instance'); + }); }); diff --git a/apps/mobile/src/lib/kiloclaw-display.ts b/apps/mobile/src/lib/kiloclaw-display.ts index 3a67600b24..d754d1e898 100644 --- a/apps/mobile/src/lib/kiloclaw-display.ts +++ b/apps/mobile/src/lib/kiloclaw-display.ts @@ -19,3 +19,10 @@ export function kiloclawConversationEyebrow(instance: KiloClawDisplayInstance | firstDisplayValue([instance?.botName, instance?.name, instance?.organizationName]) ?? 'KiloClaw' ); } + +export function kiloclawInstanceSwitcherTitle(instance: KiloClawDisplayInstance | undefined) { + return ( + firstDisplayValue([instance?.botName, instance?.name, instance?.organizationName]) ?? + 'KiloClaw instance' + ); +} From 583b1ae756b35468725132a446d923dcf973b2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:24:04 +0200 Subject: [PATCH 263/289] fix(mobile): move kilo chat rename route --- .../app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx | 2 +- .../chat/[sandbox-id]/[conversation-id].tsx | 1 + .../[sandbox-id] => }/rename-conversation.tsx | 8 ++------ .../kilo-chat/conversation-screen.tsx | 17 ++++++++++++++--- apps/mobile/src/lib/kilo-chat-routes.ts | 4 +++- 5 files changed, 21 insertions(+), 11 deletions(-) rename apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/{chat/[sandbox-id] => }/rename-conversation.tsx (89%) diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index 64d63d7df5..714c088adc 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -9,7 +9,7 @@ export default function KiloClawLayout() { ); diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/rename-conversation.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx similarity index 89% rename from apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/rename-conversation.tsx rename to apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx index 90a25099e9..a884d365ab 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/rename-conversation.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/rename-conversation.tsx @@ -7,12 +7,8 @@ import { useRenameConversation } from '@/components/kilo-chat/hooks/use-conversa export default function RenameConversationRoute() { const router = useRouter(); const client = useKiloChatClient(); - const { - 'sandbox-id': sandboxId, - conversationId, - title, - } = useLocalSearchParams<{ - 'sandbox-id': string; + const { sandboxId, conversationId, title } = useLocalSearchParams<{ + sandboxId: string; conversationId?: string; title?: string; }>(); diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 3e43b7cb84..b8841bbb7c 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -69,7 +69,12 @@ import { } from '@/lib/kilo-chat-routes'; import { setActiveChatLocation } from '@/lib/notifications'; -type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; +type Props = { + sandboxId: string; + conversationId: string; + conversationTitle: string; + conversationRenameTitle: string; +}; function editableText(message: Message): string { return message.content @@ -88,7 +93,12 @@ function MessageHistorySkeleton() { ); } -export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { +export function ConversationScreen({ + sandboxId, + conversationId, + conversationTitle, + conversationRenameTitle, +}: Props) { const client = useKiloChatClient(); const router = useRouter(); const currentUserId = useCurrentUserId(); @@ -172,7 +182,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, index => { if (index === 0) { - const params = new URLSearchParams({ conversationId, title: conversationTitle }); + const params = new URLSearchParams({ conversationId, title: conversationRenameTitle }); router.push(chatRenameConversationPath(sandboxId, params)); return; } @@ -201,6 +211,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl }, [ bottom, conversationId, + conversationRenameTitle, conversationTitle, leaveConversation, router, diff --git a/apps/mobile/src/lib/kilo-chat-routes.ts b/apps/mobile/src/lib/kilo-chat-routes.ts index 783b127e7c..5be5a64d74 100644 --- a/apps/mobile/src/lib/kilo-chat-routes.ts +++ b/apps/mobile/src/lib/kilo-chat-routes.ts @@ -19,7 +19,9 @@ export function chatConversationPath(sandboxId: string, conversationId: string): } export function chatRenameConversationPath(sandboxId: string, params: URLSearchParams): Href { - return `${KILOCLAW_TAB_CHAT_ROOT}/${sandboxId}/rename-conversation?${params.toString()}` as Href; + const renameParams = new URLSearchParams(params); + renameParams.set('sandboxId', sandboxId); + return `/(app)/(tabs)/(1_kiloclaw)/rename-conversation?${renameParams.toString()}` as Href; } export function chatInstancePickerPath(currentId: string): Href { From 838de708f767f0b6e9dd5f6d97d7cdec01fa6dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:24:26 +0200 Subject: [PATCH 264/289] fix(mobile): improve kilo chat list actions --- .../kilo-chat/conversation-list-screen.tsx | 55 +++++++++++++------ .../components/kilo-chat/conversation-row.tsx | 5 +- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index d47938db1e..5cfd58ad3e 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -1,11 +1,13 @@ import { FlashList } from '@shopify/flash-list'; import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, RefreshControl, View, type ViewStyle } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { QueryError } from '@/components/query-error'; +import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -44,7 +46,9 @@ type ConversationHeaderItem = { type ConversationListEntry = ConversationHeaderItem | ConversationItem; const listStyle = { flex: 1 } satisfies ViewStyle; -const listContentContainerStyle = { flexGrow: 1 } satisfies ViewStyle; +const TAB_BAR_FAB_CLEARANCE = 72; +const FAB_SIZE = 56; +const FAB_MARGIN = 16; function ConversationListSkeleton({ showHeader }: Readonly<{ showHeader?: boolean }>) { return ( @@ -84,6 +88,7 @@ function flattenConversationGroups( export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const router = useRouter(); const colors = useThemeColors(); + const { bottom } = useSafeAreaInsets(); const client = useKiloChatClient(); const listQuery = useConversations(client, sandboxId); const createConversation = useCreateConversation(client); @@ -95,6 +100,22 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const isFetchingNextPage = listQuery.isFetchingNextPage; const fetchNextPage = listQuery.fetchNextPage; const refetchConversations = listQuery.refetch; + const listContentContainerStyle = useMemo( + () => + ({ + flexGrow: 1, + paddingBottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE + FAB_SIZE + FAB_MARGIN, + }) satisfies ViewStyle, + [bottom] + ); + const createButtonStyle = useMemo( + () => + ({ + bottom: Math.max(bottom, 16) + TAB_BAR_FAB_CLEARANCE, + right: 20, + }) satisfies ViewStyle, + [bottom] + ); useInstancePresence(sandboxId); @@ -179,21 +200,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { title={sandboxLabel} size="large" className="px-[22px]" - headerRight={ - - {createConversation.isPending ? ( - - ) : ( - - )} - - } + headerRight={} /> + + {createConversation.isPending ? ( + + ) : ( + + )} + ); } diff --git a/apps/mobile/src/components/kilo-chat/conversation-row.tsx b/apps/mobile/src/components/kilo-chat/conversation-row.tsx index 6627667588..3b8b843568 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-row.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-row.tsx @@ -48,7 +48,7 @@ export function ConversationRow({ function openRenameSheet() { const params = new URLSearchParams({ conversationId: conversation.conversationId, - title: title.slice(0, CONVERSATION_TITLE_MAX_CHARS), + title: (conversation.title ?? '').slice(0, CONVERSATION_TITLE_MAX_CHARS), }); router.push(chatRenameConversationPath(sandboxId, params)); } @@ -91,11 +91,12 @@ export function ConversationRow({ { onPress(conversation.conversationId); }} + onLongPress={openActions} > From a94c11bfcadf1b809e368a5d1c9140ac88ac21a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:35:16 +0200 Subject: [PATCH 265/289] fix(mobile): polish kilo chat composer layout --- .../kilo-chat/conversation-screen.tsx | 1 + .../components/kilo-chat/message-input.tsx | 109 +++++++++--------- 2 files changed, 58 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index b8841bbb7c..7a93466422 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -581,6 +581,7 @@ export function ConversationScreen({ } /> + {disabledReason} )} - - - 160} - editable={!disabled} - onChangeText={t => { - setDraftLength(t.length); - applyMessageInputTextChange({ - text: t, - valueRef, - setCanSend, - onTyping, - }); - }} - onSubmitEditing={submit} - /> - - {showCounter ? ( - - {draftLength}/{MESSAGE_TEXT_MAX_CHARS} - - ) : null} + + + + 160} + editable={!disabled} + onChangeText={t => { + setDraftLength(t.length); + applyMessageInputTextChange({ + text: t, + valueRef, + setCanSend, + onTyping, + }); + }} + onSubmitEditing={submit} + /> - - {onCancelEdit && ( + {onCancelEdit && ( + + + + )} - + - )} - - - + + + {showCounter ? ( + + {draftLength}/{MESSAGE_TEXT_MAX_CHARS} + + ) : null} + ); From a940e4f15644539ac4b36b22541e4230ffe4bf40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:38:31 +0200 Subject: [PATCH 266/289] fix(mobile): match kilo chat composer shape --- .../components/kilo-chat/message-input.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 9f26a423d1..8e6117c4cf 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -33,6 +33,7 @@ type Props = { const messageInputTextStyle = { includeFontPadding: false, + textAlignVertical: 'center', } satisfies TextStyle; function resolveSendDisabled({ @@ -94,7 +95,7 @@ export function MessageInput({ }; return ( - + {replyingTo && ( @@ -121,12 +122,12 @@ export function MessageInput({ )} - + @@ -164,20 +165,20 @@ export function MessageInput({ onPress={submit} disabled={sendDisabled} className={cn( - 'h-10 w-10 items-center justify-center rounded-md bg-primary', + 'h-12 w-12 items-center justify-center rounded-full bg-primary', sendDisabled && 'opacity-50' )} > - - {showCounter ? ( + {showCounter ? ( + {draftLength}/{MESSAGE_TEXT_MAX_CHARS} - ) : null} - + + ) : null} ); From d9fe03c7ab5f10932537ed844980354c3aec7dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:41:56 +0200 Subject: [PATCH 267/289] fix(mobile): float kilo chat composer --- .../kilo-chat/conversation-screen.tsx | 2 +- .../src/components/kilo-chat/message-input.tsx | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 7a93466422..ff73eba106 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -579,9 +579,9 @@ export function ConversationScreen({ } : undefined } + bottomInset={bottom} /> - void; disabledReason?: string | null; clearOnSubmit?: boolean; + bottomInset?: number; }; +const COMPOSER_BOTTOM_CLEARANCE = 8; + const messageInputTextStyle = { includeFontPadding: false, textAlignVertical: 'center', @@ -64,6 +67,7 @@ export function MessageInput({ onCancelReply, disabledReason, clearOnSubmit, + bottomInset = 0, }: Props) { const colors = useThemeColors(); const valueRef = useRef(initialText); @@ -95,7 +99,10 @@ export function MessageInput({ }; return ( - + {replyingTo && ( @@ -122,12 +129,12 @@ export function MessageInput({ )} - + @@ -165,7 +172,7 @@ export function MessageInput({ onPress={submit} disabled={sendDisabled} className={cn( - 'h-12 w-12 items-center justify-center rounded-full bg-primary', + 'h-10 w-10 items-center justify-center rounded-md bg-primary', sendDisabled && 'opacity-50' )} > From af540c3688a1ae8f47bc1f661c2de631b3025e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:43:06 +0200 Subject: [PATCH 268/289] fix(mobile): restore kilo chat composer background --- apps/mobile/src/components/kilo-chat/message-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 6956292c31..6e41c9d503 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -101,7 +101,7 @@ export function MessageInput({ return ( {replyingTo && ( From c16a8f4f33db0253c3b92356b06a7ee8817f8222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:48:00 +0200 Subject: [PATCH 269/289] fix(mobile): center kilo chat composer text --- .../kilo-chat/message-input-layout.test.ts | 23 +++++++++++++++++++ .../kilo-chat/message-input-layout.ts | 19 +++++++++++++++ .../components/kilo-chat/message-input.tsx | 10 +++----- 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-input-layout.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-input-layout.ts diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts new file mode 100644 index 0000000000..8c1e8464b7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-layout.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { + MESSAGE_INPUT_BORDER_WIDTH, + MESSAGE_INPUT_LINE_HEIGHT, + MESSAGE_INPUT_MIN_HEIGHT, + messageInputTextStyle, +} from './message-input-layout'; + +describe('message input layout', () => { + it('centers a single text line inside the bordered composer input', () => { + const expectedPadding = + (MESSAGE_INPUT_MIN_HEIGHT - MESSAGE_INPUT_LINE_HEIGHT - MESSAGE_INPUT_BORDER_WIDTH * 2) / 2; + + expect(messageInputTextStyle).toMatchObject({ + includeFontPadding: false, + lineHeight: MESSAGE_INPUT_LINE_HEIGHT, + paddingBottom: expectedPadding, + paddingTop: expectedPadding, + textAlignVertical: 'top', + }); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-input-layout.ts b/apps/mobile/src/components/kilo-chat/message-input-layout.ts new file mode 100644 index 0000000000..c760d10cd7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input-layout.ts @@ -0,0 +1,19 @@ +import { type TextStyle } from 'react-native'; + +export const MESSAGE_INPUT_MIN_HEIGHT = 40; +export const MESSAGE_INPUT_MAX_HEIGHT = 128; +export const MESSAGE_INPUT_LINE_HEIGHT = 20; +export const MESSAGE_INPUT_BORDER_WIDTH = 1; + +const MESSAGE_INPUT_VERTICAL_PADDING = + (MESSAGE_INPUT_MIN_HEIGHT - MESSAGE_INPUT_LINE_HEIGHT - MESSAGE_INPUT_BORDER_WIDTH * 2) / 2; + +export const messageInputTextStyle = { + includeFontPadding: false, + lineHeight: MESSAGE_INPUT_LINE_HEIGHT, + maxHeight: MESSAGE_INPUT_MAX_HEIGHT, + minHeight: MESSAGE_INPUT_MIN_HEIGHT, + paddingBottom: MESSAGE_INPUT_VERTICAL_PADDING, + paddingTop: MESSAGE_INPUT_VERTICAL_PADDING, + textAlignVertical: 'top', +} satisfies TextStyle; diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index 6e41c9d503..cdcf8c1c32 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -1,11 +1,12 @@ import { Send, X } from 'lucide-react-native'; import { useRef, useState } from 'react'; -import { Pressable, TextInput, type TextStyle, View } from 'react-native'; +import { Pressable, TextInput, View } from 'react-native'; import { type Message, MESSAGE_TEXT_MAX_CHARS } from '@kilocode/kilo-chat'; import { Text } from '@/components/ui/text'; import { cn } from '@/lib/utils'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { messageInputTextStyle } from './message-input-layout'; import { applyMessageInputTextChange, isMessageInputOverLimit, @@ -34,11 +35,6 @@ type Props = { const COMPOSER_BOTTOM_CLEARANCE = 8; -const messageInputTextStyle = { - includeFontPadding: false, - textAlignVertical: 'center', -} satisfies TextStyle; - function resolveSendDisabled({ canSend, disabled, @@ -134,7 +130,7 @@ export function MessageInput({ Date: Mon, 4 May 2026 18:50:38 +0200 Subject: [PATCH 270/289] fix(kilo-chat): avoid toSorted in message cache --- .../kilo-chat-hooks/src/use-messages.test.ts | 41 +++++++++++++++++++ packages/kilo-chat-hooks/src/use-messages.ts | 8 ++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/kilo-chat-hooks/src/use-messages.test.ts b/packages/kilo-chat-hooks/src/use-messages.test.ts index 9595830f67..d7ac9f8928 100644 --- a/packages/kilo-chat-hooks/src/use-messages.test.ts +++ b/packages/kilo-chat-hooks/src/use-messages.test.ts @@ -492,6 +492,47 @@ describe('message pagination helpers', () => { }); describe('send message cache settlement', () => { + it('orders settled messages without Array.prototype.toSorted for Hermes clients', () => { + const originalToSorted = Array.prototype.toSorted; + Object.defineProperty(Array.prototype, 'toSorted', { + configurable: true, + value: undefined, + }); + + try { + const pendingMessage = message({ + id: 'pending-01KQK8Y1111111111111111111', + senderId: 'user-current', + content: textContent('still sending'), + }); + const olderServerMessage = message({ id: '01KQK8Y2222222222222222222' }); + const middleServerMessage = message({ id: '01KQK8Y3333333333333333333' }); + const newestServerMessage = message({ id: '01KQK8Y4444444444444444444' }); + const initial: MessageInfiniteData = { + pageParams: [undefined], + pages: [messagePage([pendingMessage, olderServerMessage, middleServerMessage])], + }; + + const result = applyCreateMessageResponseToPages(initial, 'pending-missing', { + messageId: newestServerMessage.id, + clientId: '01KQK8Y5555555555555555555', + message: newestServerMessage, + }); + + expect(result.pages[0]?.messages.map(({ id }) => id)).toEqual([ + newestServerMessage.id, + pendingMessage.id, + middleServerMessage.id, + olderServerMessage.id, + ]); + } finally { + Object.defineProperty(Array.prototype, 'toSorted', { + configurable: true, + value: originalToSorted, + }); + } + }); + it('creates the first page for an optimistic send when the messages cache is cold', () => { const queryClient = new QueryClient(); const queryKey = messagesKey('01KQK8Y0000000000000000000'); diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index 78178d9389..98e5189fa7 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -403,9 +403,11 @@ function errorCode(error: unknown): string | null { } function orderNewestLoadedPageByServerId(page: MessagePage): MessagePage { - const orderedServerMessages = page.messages - .filter(message => !message.id.startsWith('pending-')) - .toSorted((left, right) => right.id.localeCompare(left.id)); + const serverMessages = page.messages.filter(message => !message.id.startsWith('pending-')); + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; spread keeps the filtered array immutable. + const orderedServerMessages = [...serverMessages].sort((left, right) => + right.id.localeCompare(left.id) + ); let orderedServerMessageIndex = 0; return withPageMessages( From e26d605e8ab5266904cdeda7cdb525e853d5df80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 18:59:46 +0200 Subject: [PATCH 271/289] fix(mobile): improve kilo chat message display --- .../chat/[sandbox-id]/[conversation-id].tsx | 1 + .../agents/markdown-palette.test.ts | 39 ++++++++++++++ .../src/components/agents/markdown-palette.ts | 4 +- .../kilo-chat/conversation-screen.tsx | 5 ++ .../components/kilo-chat/message-bubble.tsx | 16 +++--- .../components/kilo-chat/message-input.tsx | 2 +- .../src/components/kilo-chat/message-list.tsx | 6 +++ .../components/kilo-chat/message-markdown.tsx | 5 +- .../kilo-chat/message-presentation.test.ts | 33 ++++++++++++ .../kilo-chat/message-presentation.ts | 28 ++++++++++ packages/kilo-chat/src/schemas.ts | 7 ++- .../__tests__/conversations-routes.test.ts | 52 +++++++++++++++++++ .../kilo-chat/src/routes/conversations.ts | 16 +++++- .../plugins/kilo-chat/src/synced/schemas.ts | 7 ++- 14 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/components/agents/markdown-palette.test.ts diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx index 395f5085de..320e5e0232 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/[sandbox-id]/[conversation-id].tsx @@ -56,6 +56,7 @@ export default function ChatConversationRoute() { conversationId={conversationId} conversationTitle={conversationDetail.data.title ?? 'Untitled'} conversationRenameTitle={conversationDetail.data.title ?? ''} + conversationMembers={conversationDetail.data.members} /> ); diff --git a/apps/mobile/src/components/agents/markdown-palette.test.ts b/apps/mobile/src/components/agents/markdown-palette.test.ts new file mode 100644 index 0000000000..764048f9e5 --- /dev/null +++ b/apps/mobile/src/components/agents/markdown-palette.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { getPalette } from './markdown-palette'; +import { type ThemeColors } from '@/lib/hooks/use-theme-colors'; + +const colors = { + background: '#FBFAF5', + foreground: '#14130F', + primary: '#4F5A10', + primaryForeground: '#FFFFFF', + secondary: '#F0EEE6', + secondaryForeground: '#14130F', + muted: '#F0EEE6', + mutedForeground: '#7A756B', + destructive: '#C25647', + border: 'rgba(20, 15, 10, 0.09)', + card: '#FFFFFF', + ink2: '#3C382F', + mutedSoft: '#A9A39A', + hairSoft: 'rgba(20, 15, 10, 0.05)', + accentSoft: '#E8F27A', + accentSoftForeground: '#1A1A10', + good: '#2F9A5F', + warn: '#B27214', + agentYuki: '#6B4FD6', + agentWorkclaw: '#4F5A10', + agentCloud: '#2F9A5F', + agentKilocode: '#B27214', + agentCoral: '#C25647', + agentSky: '#2C7FB0', +} satisfies ThemeColors; + +describe('markdown palette', () => { + it('uses white text for user-authored chat bubbles', () => { + const palette = getPalette('user', colors); + + expect(palette.textColor).toBe('#FFFFFF'); + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-palette.ts b/apps/mobile/src/components/agents/markdown-palette.ts index 4f1bba61bf..e2279c8f40 100644 --- a/apps/mobile/src/components/agents/markdown-palette.ts +++ b/apps/mobile/src/components/agents/markdown-palette.ts @@ -33,9 +33,7 @@ function withAlpha(color: string, alpha: number): string { export function getPalette(variant: MarkdownVariant, colors: ThemeColors): MarkdownPalette { if (variant === 'user') { - // User bubbles sit on `accent-soft` (lime); ink-on-lime is the correct - // foreground, and translucent ink produces subtle codespan / divider tints. - const ink = colors.accentSoftForeground; + const ink = '#FFFFFF'; return { textColor: ink, mutedTextColor: withAlpha(ink, 0.7), diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index ff73eba106..5a45a54580 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -21,6 +21,7 @@ import { import { buildMessageActionAvailability, contentBlocksToText, + type ConversationDetailResponse, type ExecApprovalDecision, formatKiloChatError, type Message, @@ -74,6 +75,7 @@ type Props = { conversationId: string; conversationTitle: string; conversationRenameTitle: string; + conversationMembers: ConversationDetailResponse['members']; }; function editableText(message: Message): string { @@ -98,6 +100,7 @@ export function ConversationScreen({ conversationId, conversationTitle, conversationRenameTitle, + conversationMembers, }: Props) { const client = useKiloChatClient(); const router = useRouter(); @@ -549,6 +552,8 @@ export function ConversationScreen({ void; @@ -49,6 +50,7 @@ function MessageBubbleComponent({ currentUserId, isFromMe, showAuthor, + authorLabel, pendingActionGroupId, replyToMessage, onExecuteAction, @@ -59,7 +61,6 @@ function MessageBubbleComponent({ const isPending = message.id.startsWith('pending-'); const timestamp = message.clientUpdatedAt ?? message.updatedAt; const edited = isMessageEdited(message); - const authorLabel = message.senderId.startsWith('bot:') ? 'KiloClaw' : message.senderId; function handleReactionPress(emoji: string) { onReactionPress(message, emoji); @@ -69,7 +70,7 @@ function MessageBubbleComponent({ onExecuteAction(message, groupId, value); } - const textColor = isFromMe ? 'text-primary-foreground' : 'text-foreground'; + const textColor = isFromMe ? 'text-white' : 'text-foreground'; const deliveryFailureLabel = getDeliveryFailureLabel(message); return ( @@ -95,7 +96,7 @@ function MessageBubbleComponent({ {message.deleted ? ( @@ -106,7 +107,7 @@ function MessageBubbleComponent({ @@ -126,10 +127,7 @@ function MessageBubbleComponent({ const Icon = block.resolved.value.startsWith('allow') ? CheckCircle2 : XCircle; return ( - + {label} ); @@ -168,7 +166,7 @@ function MessageBubbleComponent({ {formatTimestamp(timestamp)} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx index cdcf8c1c32..de88624ad6 100644 --- a/apps/mobile/src/components/kilo-chat/message-input.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -125,7 +125,7 @@ export function MessageInput({ )} - + void; isFetchingOlder: boolean; pendingAction: PendingAction | null; @@ -21,6 +24,8 @@ type Props = { export function MessageList({ messages, currentUserId, + members, + botName, fetchOlder, isFetchingOlder, pendingAction, @@ -53,6 +58,7 @@ export function MessageList({ currentUserId={currentUserId} isFromMe={currentUserId !== null && item.senderId === currentUserId} showAuthor={showAuthor} + authorLabel={resolveMessageAuthorLabel({ senderId: item.senderId, members, botName })} pendingActionGroupId={pendingActionGroupIdForMessage(pendingAction, item.id)} replyToMessage={ item.inReplyToMessageId diff --git a/apps/mobile/src/components/kilo-chat/message-markdown.tsx b/apps/mobile/src/components/kilo-chat/message-markdown.tsx index c790fa5603..21a083e846 100644 --- a/apps/mobile/src/components/kilo-chat/message-markdown.tsx +++ b/apps/mobile/src/components/kilo-chat/message-markdown.tsx @@ -16,7 +16,10 @@ export function MessageMarkdown({ text, isFromMe }: Readonly; } catch { return ( - + {text} ); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index eed4e79979..ac509219c3 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -9,6 +9,7 @@ import { getDeliveryFailureLabel, getReplyPreviewText, isMessageEdited, + resolveMessageAuthorLabel, } from './message-presentation'; vi.mock('expo-crypto', () => ({ @@ -151,3 +152,35 @@ describe('canToggleReaction', () => { expect(canToggleReaction(message(), 'user-1')).toBe(true); }); }); + +describe('resolveMessageAuthorLabel', () => { + it('uses resolved display names for user senders', () => { + expect( + resolveMessageAuthorLabel({ + senderId: 'user-1', + members: [ + { id: 'user-1', kind: 'user', displayName: 'Igor Minar', avatarUrl: null }, + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot', displayName: null, avatarUrl: null }, + ], + botName: 'Helper Bot', + }) + ).toBe('Igor Minar'); + }); + + it('uses the bot display name for bot senders', () => { + expect( + resolveMessageAuthorLabel({ + senderId: 'bot:kiloclaw:sandbox-1', + members: [ + { id: 'bot:kiloclaw:sandbox-1', kind: 'bot', displayName: null, avatarUrl: null }, + ], + botName: 'Helper Bot', + }) + ).toBe('Helper Bot'); + }); + + it('falls back to stable labels when resolved names are missing', () => { + expect(resolveMessageAuthorLabel({ senderId: 'bot:kiloclaw:sandbox-1' })).toBe('KiloClaw'); + expect(resolveMessageAuthorLabel({ senderId: 'user-1' })).toBe('user-1'); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index f1a6267002..828f17cccc 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -1,4 +1,5 @@ import { + type ConversationDetailResponse, type CreateMessageRequest, type Message, type ReplyToMessageSnapshot, @@ -8,6 +9,7 @@ import { ulid } from 'ulid'; type SendMessageVariables = CreateMessageRequest & { clientId: string }; export type ReplyPreviewSource = Message | ReplyToMessageSnapshot; +export type MessageAuthorMember = ConversationDetailResponse['members'][number]; type BuildSendMessageVariablesInput = { conversationId: string; @@ -82,3 +84,29 @@ export function canCopyMessage(message: Message): boolean { export function isMessageEdited(message: Message): boolean { return !message.deleted && message.clientUpdatedAt !== null; } + +function firstDisplayValue(values: readonly (string | null | undefined)[]): string | null { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return null; +} + +export function resolveMessageAuthorLabel({ + senderId, + members = [], + botName, +}: { + senderId: string; + members?: readonly MessageAuthorMember[]; + botName?: string | null; +}): string { + const member = members.find(candidate => candidate.id === senderId); + if (senderId.startsWith('bot:')) { + return firstDisplayValue([botName, member?.displayName]) ?? 'KiloClaw'; + } + return firstDisplayValue([member?.displayName]) ?? senderId; +} diff --git a/packages/kilo-chat/src/schemas.ts b/packages/kilo-chat/src/schemas.ts index 1445bd3088..348201f7e2 100644 --- a/packages/kilo-chat/src/schemas.ts +++ b/packages/kilo-chat/src/schemas.ts @@ -140,6 +140,11 @@ export const conversationMemberSchema = z.object({ kind: memberKindSchema, }); +export const conversationDetailMemberSchema = conversationMemberSchema.extend({ + displayName: z.string().nullish(), + avatarUrl: z.string().nullish(), +}); + export const enrichedConversationMemberSchema = z.object({ id: z.string(), kind: z.string(), @@ -167,7 +172,7 @@ export const conversationDetailSchema = z.object({ title: z.string().nullable(), createdBy: z.string(), createdAt: z.number(), - members: z.array(conversationMemberSchema), + members: z.array(conversationDetailMemberSchema), }); // ── Request / response schemas ────────────────────────────────────── diff --git a/services/kilo-chat/src/__tests__/conversations-routes.test.ts b/services/kilo-chat/src/__tests__/conversations-routes.test.ts index 76d422b9f9..b01172a2a9 100644 --- a/services/kilo-chat/src/__tests__/conversations-routes.test.ts +++ b/services/kilo-chat/src/__tests__/conversations-routes.test.ts @@ -6,12 +6,21 @@ import { makeApp } from './helpers'; /** Map of userId → set of sandbox IDs they own. */ const ownershipMap = new Map>(); +const userLookupResults = new Map< + string, + { displayName: string | null; avatarUrl: string | null } +>(); vi.mock('../services/sandbox-ownership', () => ({ userOwnsSandbox: async (_env: Env, userId: string, sandboxId: string) => ownershipMap.get(userId)?.has(sandboxId) ?? false, })); +vi.mock('../services/user-lookup', () => ({ + resolveUserDisplayInfo: async (_conn: string, userIds: string[]) => + new Map(userIds.map(userId => [userId, userLookupResults.get(userId) ?? null])), +})); + function grantSandbox(userId: string, sandboxId: string) { if (!ownershipMap.has(userId)) ownershipMap.set(userId, new Set()); ownershipMap.get(userId)!.add(sandboxId); @@ -281,6 +290,49 @@ describe('GET /v1/conversations/:id', () => { expect(Array.isArray(body.members)).toBe(true); }); + it('enriches member display info for sender labels', async () => { + userLookupResults.set('user-member-display', { + displayName: 'Member Display', + avatarUrl: 'https://example.com/member.png', + }); + grantSandbox('user-member-display', 'sandbox-member-display'); + const app = makeApp('user-member-display', 'user'); + const createRes = await app.request( + '/v1/conversations', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sandboxId: 'sandbox-member-display', title: 'Names Chat' }), + }, + env + ); + const { conversationId } = await createRes.json<{ conversationId: string }>(); + + const res = await app.request(`/v1/conversations/${conversationId}`, {}, env); + + expect(res.status).toBe(200); + const body = await res.json<{ + members: Array<{ + id: string; + kind: string; + displayName?: string | null; + avatarUrl?: string | null; + }>; + }>(); + expect(body.members).toContainEqual({ + id: 'user-member-display', + kind: 'user', + displayName: 'Member Display', + avatarUrl: 'https://example.com/member.png', + }); + expect(body.members).toContainEqual({ + id: 'bot:kiloclaw:sandbox-member-display', + kind: 'bot', + displayName: null, + avatarUrl: null, + }); + }); + it('returns 403 for non-member', async () => { // Create conversation as user-grace grantSandbox('user-grace', 'sandbox-grace'); diff --git a/services/kilo-chat/src/routes/conversations.ts b/services/kilo-chat/src/routes/conversations.ts index 456dc6d0d3..0a4cbfa53f 100644 --- a/services/kilo-chat/src/routes/conversations.ts +++ b/services/kilo-chat/src/routes/conversations.ts @@ -14,6 +14,7 @@ import { leaveConversationFor, markReadFor, } from '../services/conversations'; +import { resolveUserDisplayInfo, type UserDisplayInfo } from '../services/user-lookup'; import { ulidSchema, createConversationRequestSchema, @@ -107,7 +108,20 @@ export function registerConversationRoutes( if (!info || !info.members.some(m => m.id === callerId)) { return c.json({ error: 'Forbidden' }, 403); } - return c.json(info satisfies ConversationDetailResponse); + const userIds = info.members.filter(m => m.kind === 'user').map(m => m.id); + const displayInfo = + userIds.length > 0 + ? await resolveUserDisplayInfo(c.env.HYPERDRIVE.connectionString, userIds) + : new Map(); + const enrichedInfo = { + ...info, + members: info.members.map(member => ({ + ...member, + displayName: displayInfo.get(member.id)?.displayName ?? null, + avatarUrl: displayInfo.get(member.id)?.avatarUrl ?? null, + })), + }; + return c.json(enrichedInfo satisfies ConversationDetailResponse); }); // PATCH /v1/conversations/:id — rename diff --git a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts index 1445bd3088..348201f7e2 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/synced/schemas.ts @@ -140,6 +140,11 @@ export const conversationMemberSchema = z.object({ kind: memberKindSchema, }); +export const conversationDetailMemberSchema = conversationMemberSchema.extend({ + displayName: z.string().nullish(), + avatarUrl: z.string().nullish(), +}); + export const enrichedConversationMemberSchema = z.object({ id: z.string(), kind: z.string(), @@ -167,7 +172,7 @@ export const conversationDetailSchema = z.object({ title: z.string().nullable(), createdBy: z.string(), createdAt: z.number(), - members: z.array(conversationMemberSchema), + members: z.array(conversationDetailMemberSchema), }); // ── Request / response schemas ────────────────────────────────────── From feb6504cc743b73d0de1d3e04676a850f9b63ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 19:09:24 +0200 Subject: [PATCH 272/289] fix(mobile): add swipe reply for kilo chat --- .../kilo-chat/conversation-screen.tsx | 14 + .../components/kilo-chat/message-bubble.tsx | 333 +++++++++++------- .../kilo-chat/message-gesture-state.test.ts | 42 +++ .../kilo-chat/message-gesture-state.ts | 28 ++ .../src/components/kilo-chat/message-list.tsx | 3 + 5 files changed, 296 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-gesture-state.ts diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 5a45a54580..f36aa9837f 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -408,6 +408,19 @@ export function ConversationScreen({ showActionSheetWithOptions, ] ); + const handleSwipeReplyMessage = useCallback( + (message: Message) => { + const isOwnMessage = currentUserId !== null && message.senderId === currentUserId; + const actionAvailability = buildMessageActionAvailability(message, isOwnMessage); + if (!actionAvailability.canReply) { + return; + } + setEditingMessage(null); + setReplyingTo(message); + void Haptics.selectionAsync(); + }, + [currentUserId] + ); useConversationPresence(sandboxId, conversationId); useConversationEventSubscription(sandboxId, conversationId); @@ -559,6 +572,7 @@ export function ConversationScreen({ pendingAction={pendingAction} onExecuteAction={handleExecuteAction} onLongPressMessage={handleLongPressMessage} + onSwipeReplyMessage={handleSwipeReplyMessage} onReactionPress={handleReactionPress} /> diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index af8165c389..6341cf389c 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -1,12 +1,25 @@ import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; -import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react-native'; +import { AlertCircle, CheckCircle2, Reply, XCircle } from 'lucide-react-native'; import { memo } from 'react'; import { Pressable, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { scheduleOnRN } from 'react-native-worklets'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; +import { + shouldStartReplyFromSwipe, + SWIPE_REPLY_DISTANCE, + SWIPE_REPLY_MAX_TRANSLATE, +} from './message-gesture-state'; import { MessageMarkdown } from './message-markdown'; import { canShowReactionPills, @@ -27,6 +40,7 @@ type Props = { onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; onReactionPress: (message: Message, emoji: string) => void; onLongPress?: (m: Message) => void; + onSwipeReply?: (m: Message) => void; }; function formatTimestamp(ms: number): string { @@ -56,11 +70,16 @@ function MessageBubbleComponent({ onExecuteAction, onReactionPress, onLongPress, + onSwipeReply, }: Props) { const colors = useThemeColors(); const isPending = message.id.startsWith('pending-'); const timestamp = message.clientUpdatedAt ?? message.updatedAt; const edited = isMessageEdited(message); + const swipeX = useSharedValue(0); + const replyProgress = useSharedValue(0); + const canSwipeReply = + onSwipeReply !== undefined && !isPending && !message.deleted && !message.deliveryFailed; function handleReactionPress(emoji: string) { onReactionPress(message, emoji); @@ -70,146 +89,212 @@ function MessageBubbleComponent({ onExecuteAction(message, groupId, value); } + function handleSwipeReply() { + onSwipeReply?.(message); + } + + // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). + const swipeGesture = Gesture.Pan() + .activeOffsetX([-12, 12]) + .onUpdate(event => { + if (!canSwipeReply) { + return; + } + const nextX = Math.max(Math.min(event.translationX, 0), -SWIPE_REPLY_MAX_TRANSLATE); + swipeX.value = nextX; + replyProgress.value = Math.min(Math.abs(nextX) / SWIPE_REPLY_DISTANCE, 1); + }) + .onEnd(event => { + const shouldReply = shouldStartReplyFromSwipe({ + canReply: canSwipeReply, + translationX: event.translationX, + velocityX: event.velocityX, + }); + if (shouldReply) { + scheduleOnRN(handleSwipeReply); + } + swipeX.value = withTiming(0, { duration: 180, easing: Easing.out(Easing.cubic) }); + replyProgress.value = withTiming(0, { duration: 140 }); + }) + .onFinalize(() => { + swipeX.value = withTiming(0, { duration: 180, easing: Easing.out(Easing.cubic) }); + replyProgress.value = withTiming(0, { duration: 140 }); + }); + + const swipeStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: swipeX.value }], + })); + const replyHintStyle = useAnimatedStyle(() => ({ + opacity: replyProgress.value, + transform: [{ scale: 0.85 + replyProgress.value * 0.15 }], + })); + const textColor = isFromMe ? 'text-white' : 'text-foreground'; const deliveryFailureLabel = getDeliveryFailureLabel(message); return ( - { - onLongPress(message); - } - : undefined - } - className={cn('px-4 py-1', isFromMe ? 'items-end' : 'items-start', isPending && 'opacity-50')} - > - {showAuthor && ( - - {authorLabel} - {timestamp !== null && ( - {formatTimestamp(timestamp)} - )} - - )} - - + { + onLongPress(message); + } + : undefined + } className={cn( - 'max-w-[80%] rounded-2xl px-3 py-2', - isFromMe ? 'bg-primary' : 'border border-border bg-card dark:bg-secondary' + 'px-4 py-1', + isFromMe ? 'items-end' : 'items-start', + isPending && 'opacity-50' )} > - {message.deleted ? ( - [deleted message] - ) : ( - <> - {replyToMessage && ( - - - {getReplyPreviewText(replyToMessage)} - - - )} - {message.content.map((block, index) => { - if (block.type === 'text') { - return ; - } - - // block.type === 'actions' - if (block.resolved) { - const resolvedAction = block.actions.find(a => a.value === block.resolved?.value); - const label = resolvedAction?.label ?? block.resolved.value; - const Icon = block.resolved.value.startsWith('allow') ? CheckCircle2 : XCircle; - return ( - - - {label} - - ); - } + {canSwipeReply && ( + + + + + + )} - return ( - - {block.actions.map(action => ( - - ))} - - ); - })} - {deliveryFailureLabel && ( - - - - {deliveryFailureLabel} + + {showAuthor && ( + + {authorLabel} + {timestamp !== null && ( + + {formatTimestamp(timestamp)} - - )} - - )} + )} + + )} - {!showAuthor && timestamp !== null && ( - - {formatTimestamp(timestamp)} - {edited ? ' (edited)' : ''} - - )} - + {message.deleted ? ( + [deleted message] + ) : ( + <> + {replyToMessage && ( + + + {getReplyPreviewText(replyToMessage)} + + + )} + {message.content.map((block, index) => { + if (block.type === 'text') { + return ; + } - {canShowReactionPills(message) && ( - - {message.reactions.map(reaction => { - const hasReacted = currentUserId ? reaction.memberIds.includes(currentUserId) : false; - return ( - { - handleReactionPress(reaction.emoji); - }} + // block.type === 'actions' + if (block.resolved) { + const resolvedAction = block.actions.find( + action => action.value === block.resolved?.value + ); + const label = resolvedAction?.label ?? block.resolved.value; + const Icon = block.resolved.value.startsWith('allow') ? CheckCircle2 : XCircle; + return ( + + + {label} + + ); + } + + return ( + + {block.actions.map(action => ( + + ))} + + ); + })} + {deliveryFailureLabel && ( + + + + {deliveryFailureLabel} + + + )} + + )} + + {!showAuthor && timestamp !== null && ( + - {reaction.emoji} - - {reaction.count} - - - ); - })} - - )} - + {formatTimestamp(timestamp)} + {edited ? ' (edited)' : ''} + + )} + + + {canShowReactionPills(message) && ( + + {message.reactions.map(reaction => { + const hasReacted = currentUserId + ? reaction.memberIds.includes(currentUserId) + : false; + return ( + { + handleReactionPress(reaction.emoji); + }} + className={cn( + 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1', + hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' + )} + > + {reaction.emoji} + + {reaction.count} + + + ); + })} + + )} + + + ); } diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts new file mode 100644 index 0000000000..706925841c --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldStartReplyFromSwipe } from './message-gesture-state'; + +describe('shouldStartReplyFromSwipe', () => { + it('starts reply on a committed left swipe when reply is available', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: -64, + velocityX: -120, + }) + ).toBe(true); + }); + + it('ignores short left drags and right swipes', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: -24, + velocityX: -100, + }) + ).toBe(false); + expect( + shouldStartReplyFromSwipe({ + canReply: true, + translationX: 72, + velocityX: 500, + }) + ).toBe(false); + }); + + it('ignores swipe gestures when the message cannot be replied to', () => { + expect( + shouldStartReplyFromSwipe({ + canReply: false, + translationX: -80, + velocityX: -700, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts new file mode 100644 index 0000000000..1425d3f88f --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts @@ -0,0 +1,28 @@ +export const SWIPE_REPLY_DISTANCE = 56; +export const SWIPE_REPLY_FAST_DISTANCE = 24; +export const SWIPE_REPLY_FAST_VELOCITY = -650; +export const SWIPE_REPLY_MAX_TRANSLATE = 72; + +type SwipeReplyInput = { + canReply: boolean; + translationX: number; + velocityX: number; +}; + +export function shouldStartReplyFromSwipe({ + canReply, + translationX, + velocityX, +}: SwipeReplyInput): boolean { + 'worklet'; + + if (!canReply || translationX >= 0) { + return false; + } + + const distance = Math.abs(translationX); + return ( + distance >= SWIPE_REPLY_DISTANCE || + (distance >= SWIPE_REPLY_FAST_DISTANCE && velocityX <= SWIPE_REPLY_FAST_VELOCITY) + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index f6a5f6b1cd..8a677c530b 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -19,6 +19,7 @@ type Props = { onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; onReactionPress: (message: Message, emoji: string) => void; onLongPressMessage?: (m: Message) => void; + onSwipeReplyMessage?: (m: Message) => void; }; export function MessageList({ @@ -32,6 +33,7 @@ export function MessageList({ onExecuteAction, onReactionPress, onLongPressMessage, + onSwipeReplyMessage, }: Props) { // useMessages returns messages oldest-to-newest. // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition @@ -68,6 +70,7 @@ export function MessageList({ onExecuteAction={onExecuteAction} onReactionPress={onReactionPress} onLongPress={onLongPressMessage} + onSwipeReply={onSwipeReplyMessage} /> ); }} From 170731bb01028ccd74f877d043a5914d811351b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 19:11:16 +0200 Subject: [PATCH 273/289] fix(mobile): animate kilo chat message long press --- .../components/kilo-chat/message-bubble.tsx | 104 +++++++++--------- .../kilo-chat/message-gesture-state.test.ts | 19 +++- .../kilo-chat/message-gesture-state.ts | 35 ++++++ .../kilo-chat/message-reaction-pills.tsx | 59 ++++++++++ 4 files changed, 166 insertions(+), 51 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index 6341cf389c..1c369990f1 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -8,6 +8,7 @@ import Animated, { Easing, useAnimatedStyle, useSharedValue, + withSequence, withTiming, } from 'react-native-reanimated'; @@ -16,18 +17,19 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; import { + resolveLongPressFeedback, shouldStartReplyFromSwipe, SWIPE_REPLY_DISTANCE, SWIPE_REPLY_MAX_TRANSLATE, } from './message-gesture-state'; import { MessageMarkdown } from './message-markdown'; import { - canShowReactionPills, getDeliveryFailureLabel, getReplyPreviewText, isMessageEdited, type ReplyPreviewSource, } from './message-presentation'; +import { MessageReactionPills } from './message-reaction-pills'; type Props = { message: Message; @@ -78,13 +80,11 @@ function MessageBubbleComponent({ const edited = isMessageEdited(message); const swipeX = useSharedValue(0); const replyProgress = useSharedValue(0); + const pressScale = useSharedValue(1); + const longPressHighlight = useSharedValue(0); const canSwipeReply = onSwipeReply !== undefined && !isPending && !message.deleted && !message.deliveryFailed; - function handleReactionPress(emoji: string) { - onReactionPress(message, emoji); - } - function handleExecuteAction(groupId: string, value: ExecApprovalDecision) { onExecuteAction(message, groupId, value); } @@ -93,6 +93,36 @@ function MessageBubbleComponent({ onSwipeReply?.(message); } + function handlePressIn() { + const feedback = resolveLongPressFeedback({ pressed: true, longPressed: false }); + pressScale.value = withTiming(feedback.scale, { + duration: 120, + easing: Easing.out(Easing.cubic), + }); + } + + function handlePressOut() { + const feedback = resolveLongPressFeedback({ pressed: false, longPressed: false }); + pressScale.value = withTiming(feedback.scale, { + duration: 160, + easing: Easing.out(Easing.cubic), + }); + longPressHighlight.value = withTiming(feedback.highlightOpacity, { duration: 180 }); + } + + function handleLongPress() { + const feedback = resolveLongPressFeedback({ pressed: true, longPressed: true }); + pressScale.value = withSequence( + withTiming(feedback.scale, { duration: 90, easing: Easing.out(Easing.cubic) }), + withTiming(1, { duration: 180, easing: Easing.out(Easing.cubic) }) + ); + longPressHighlight.value = withSequence( + withTiming(feedback.highlightOpacity, { duration: 90 }), + withTiming(0, { duration: 260 }) + ); + onLongPress?.(message); + } + // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). const swipeGesture = Gesture.Pan() .activeOffsetX([-12, 12]) @@ -122,12 +152,15 @@ function MessageBubbleComponent({ }); const swipeStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: swipeX.value }], + transform: [{ translateX: swipeX.value }, { scale: pressScale.value }], })); const replyHintStyle = useAnimatedStyle(() => ({ opacity: replyProgress.value, transform: [{ scale: 0.85 + replyProgress.value * 0.15 }], })); + const longPressHighlightStyle = useAnimatedStyle(() => ({ + opacity: longPressHighlight.value, + })); const textColor = isFromMe ? 'text-white' : 'text-foreground'; const deliveryFailureLabel = getDeliveryFailureLabel(message); @@ -135,13 +168,9 @@ function MessageBubbleComponent({ return ( { - onLongPress(message); - } - : undefined - } + onPressIn={handlePressIn} + onPressOut={handlePressOut} + onLongPress={onLongPress ? handleLongPress : undefined} className={cn( 'px-4 py-1', isFromMe ? 'items-end' : 'items-start', @@ -174,10 +203,15 @@ function MessageBubbleComponent({ + {message.deleted ? ( [deleted message] ) : ( @@ -256,42 +290,12 @@ function MessageBubbleComponent({ )} - {canShowReactionPills(message) && ( - - {message.reactions.map(reaction => { - const hasReacted = currentUserId - ? reaction.memberIds.includes(currentUserId) - : false; - return ( - { - handleReactionPress(reaction.emoji); - }} - className={cn( - 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1', - hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' - )} - > - {reaction.emoji} - - {reaction.count} - - - ); - })} - - )} + diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts index 706925841c..d35a8d2fb2 100644 --- a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { shouldStartReplyFromSwipe } from './message-gesture-state'; +import { resolveLongPressFeedback, shouldStartReplyFromSwipe } from './message-gesture-state'; describe('shouldStartReplyFromSwipe', () => { it('starts reply on a committed left swipe when reply is available', () => { @@ -40,3 +40,20 @@ describe('shouldStartReplyFromSwipe', () => { ).toBe(false); }); }); + +describe('resolveLongPressFeedback', () => { + it('keeps press and long-press feedback subtle', () => { + expect(resolveLongPressFeedback({ pressed: false, longPressed: false })).toEqual({ + scale: 1, + highlightOpacity: 0, + }); + expect(resolveLongPressFeedback({ pressed: true, longPressed: false })).toEqual({ + scale: 0.985, + highlightOpacity: 0, + }); + expect(resolveLongPressFeedback({ pressed: true, longPressed: true })).toEqual({ + scale: 0.97, + highlightOpacity: 1, + }); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts index 1425d3f88f..b216c9b031 100644 --- a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts @@ -2,6 +2,9 @@ export const SWIPE_REPLY_DISTANCE = 56; export const SWIPE_REPLY_FAST_DISTANCE = 24; export const SWIPE_REPLY_FAST_VELOCITY = -650; export const SWIPE_REPLY_MAX_TRANSLATE = 72; +export const LONG_PRESS_FEEDBACK_PRESS_SCALE = 0.985; +export const LONG_PRESS_FEEDBACK_ACTIVE_SCALE = 0.97; +export const LONG_PRESS_FEEDBACK_HIGHLIGHT_OPACITY = 1; type SwipeReplyInput = { canReply: boolean; @@ -9,6 +12,16 @@ type SwipeReplyInput = { velocityX: number; }; +type LongPressFeedbackInput = { + pressed: boolean; + longPressed: boolean; +}; + +type LongPressFeedback = { + scale: number; + highlightOpacity: number; +}; + export function shouldStartReplyFromSwipe({ canReply, translationX, @@ -26,3 +39,25 @@ export function shouldStartReplyFromSwipe({ (distance >= SWIPE_REPLY_FAST_DISTANCE && velocityX <= SWIPE_REPLY_FAST_VELOCITY) ); } + +export function resolveLongPressFeedback({ + pressed, + longPressed, +}: LongPressFeedbackInput): LongPressFeedback { + if (longPressed) { + return { + scale: LONG_PRESS_FEEDBACK_ACTIVE_SCALE, + highlightOpacity: LONG_PRESS_FEEDBACK_HIGHLIGHT_OPACITY, + }; + } + if (pressed) { + return { + scale: LONG_PRESS_FEEDBACK_PRESS_SCALE, + highlightOpacity: 0, + }; + } + return { + scale: 1, + highlightOpacity: 0, + }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx new file mode 100644 index 0000000000..adee4596e7 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-reaction-pills.tsx @@ -0,0 +1,59 @@ +import { type Message } from '@kilocode/kilo-chat'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { canShowReactionPills } from './message-presentation'; + +type MessageReactionPillsProps = { + message: Message; + currentUserId: string | null; + isFromMe: boolean; + onReactionPress: (message: Message, emoji: string) => void; +}; + +export function MessageReactionPills({ + message, + currentUserId, + isFromMe, + onReactionPress, +}: Readonly) { + if (!canShowReactionPills(message)) { + return null; + } + + return ( + + {message.reactions.map(reaction => { + const hasReacted = currentUserId ? reaction.memberIds.includes(currentUserId) : false; + return ( + { + onReactionPress(message, reaction.emoji); + }} + className={cn( + 'min-h-11 flex-row items-center gap-1 rounded-full px-3 py-1', + hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' + )} + > + {reaction.emoji} + + {reaction.count} + + + ); + })} + + ); +} From f411e785fb49b7c86435059f4b14c43742bfc2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 19:16:19 +0200 Subject: [PATCH 274/289] fix(mobile): disable kilo chat message text selection --- .../src/components/agents/markdown-text.tsx | 86 +++++++++++++++++-- .../components/kilo-chat/message-markdown.tsx | 11 ++- .../kilo-chat/message-presentation.test.ts | 7 ++ .../kilo-chat/message-presentation.ts | 4 + 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx index f9f39df50c..7381f866d4 100644 --- a/apps/mobile/src/components/agents/markdown-text.tsx +++ b/apps/mobile/src/components/agents/markdown-text.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useMemo } from 'react'; import { + Linking, ScrollView, Text, type TextStyle, @@ -21,6 +22,7 @@ import { type MarkdownTextProps = { value: string; variant?: MarkdownVariant; + selectable?: boolean; }; // The library's default `Renderer` renders code blocks with the `em` text @@ -37,10 +39,24 @@ type MarkdownTextProps = { // instead — readable in chat, and it avoids the Fabric measurement bug. class MarkdownRenderer extends Renderer { private readonly palette: MarkdownPalette; + private readonly selectable: boolean; - constructor(palette: MarkdownPalette) { + constructor(palette: MarkdownPalette, selectable = true) { super(); this.palette = palette; + this.selectable = selectable; + } + + private textNode(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return ( + + {children} + + ); + } + + override heading(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); } // eslint-disable-next-line eslint/max-params -- signature fixed by react-native-marked's RendererInterface @@ -53,7 +69,7 @@ class MarkdownRenderer extends Renderer { return ( { + void Linking.openURL(href); + }} + style={styles} + > + {children} + + ); + } + + override strong(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override em(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override codespan(text: string, styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + + override br(): ReactNode { + return this.textNode('\n', {}); + } + + override del(children: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(children, styles); + } + + override text(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + + override html(text: string | ReactNode[], styles?: TextStyle): ReactNode { + return this.textNode(text, styles); + } + // eslint-disable-next-line eslint/max-params -- signature fixed by react-native-marked's RendererInterface override table( header: ReactNode[][], @@ -170,7 +242,11 @@ function TableCell({ palette, width, hasRightBorder, hasBottomBorder, children } ); } -export function MarkdownText({ value, variant = 'assistant' }: Readonly) { +export function MarkdownText({ + value, + variant = 'assistant', + selectable = true, +}: Readonly) { const colorScheme = useColorScheme(); const colors = useThemeColors(); @@ -178,7 +254,7 @@ export function MarkdownText({ value, variant = 'assistant' }: Readonly; + return ( + + ); } catch { return ( {text} diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts index ac509219c3..488bbda49a 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.test.ts @@ -9,6 +9,7 @@ import { getDeliveryFailureLabel, getReplyPreviewText, isMessageEdited, + isMessageTextSelectionEnabled, resolveMessageAuthorLabel, } from './message-presentation'; @@ -106,6 +107,12 @@ describe('getDeliveryFailureLabel', () => { }); }); +describe('isMessageTextSelectionEnabled', () => { + it('disables native text selection for chat messages', () => { + expect(isMessageTextSelectionEnabled()).toBe(false); + }); +}); + describe('isMessageEdited', () => { it('marks updated non-deleted messages as edited', () => { expect(isMessageEdited(message({ clientUpdatedAt: 123 }))).toBe(true); diff --git a/apps/mobile/src/components/kilo-chat/message-presentation.ts b/apps/mobile/src/components/kilo-chat/message-presentation.ts index 828f17cccc..daec7d7167 100644 --- a/apps/mobile/src/components/kilo-chat/message-presentation.ts +++ b/apps/mobile/src/components/kilo-chat/message-presentation.ts @@ -69,6 +69,10 @@ export function getDeliveryFailureLabel(message: Message): string | null { return message.deliveryFailed ? 'Not delivered' : null; } +export function isMessageTextSelectionEnabled(): boolean { + return false; +} + export function canShowReactionPills(message: Message): boolean { return !message.deleted && message.reactions.length > 0; } From 93b67b8a0c143d8a0c9c869cc68a4d618f6478e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 19:35:35 +0200 Subject: [PATCH 275/289] fix(mobile): shorten kilo chat reaction actions --- .../components/kilo-chat/message-actions.test.ts | 13 +++++++------ .../src/components/kilo-chat/message-actions.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/message-actions.test.ts b/apps/mobile/src/components/kilo-chat/message-actions.test.ts index 2e42fd815c..dfb811f2fd 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.test.ts @@ -12,8 +12,9 @@ describe('buildMessageActionSheetOptions', () => { canDelete: false, }); - expect(options.options).toContain('👍 React'); - expect(options.options).toContain('❤️ React'); + expect(options.options).toContain('👍'); + expect(options.options).toContain('❤️'); + expect(options.options).not.toContain('👍 React'); expect(options.cancelButtonIndex).toBe(options.options.length - 1); }); @@ -141,10 +142,10 @@ describe('buildMessageActionSheetOptions', () => { }); expect(actionSheet.options).toEqual([ - '👍 React', - '❤️ React', - '😂 React', - '🎉 React', + '👍', + '❤️', + '😂', + '🎉', 'More reactions', 'Reply', 'Copy', diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index 8caeaf64d4..7c5b0ae6b1 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -37,7 +37,7 @@ export function buildMessageActionSheetOptions({ const canUseApiBackedActions = !isPendingMessage; if (canUseApiBackedActions && canReact) { for (const emoji of FIRST_REACTION_EMOJIS) { - actions.push({ kind: 'reaction', label: `${emoji} React`, emoji }); + actions.push({ kind: 'reaction', label: emoji, emoji }); } actions.push({ kind: 'more-reactions', label: 'More reactions' }); } From df6268798881a2c78ec82639b2bb475bccc44957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 4 May 2026 19:37:24 +0200 Subject: [PATCH 276/289] fix(mobile): preserve back gesture in kilo chat --- .../src/components/kilo-chat/message-bubble.tsx | 3 ++- .../kilo-chat/message-gesture-state.test.ts | 12 +++++++++++- .../components/kilo-chat/message-gesture-state.ts | 5 +++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index 1c369990f1..ae18805cbe 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -17,6 +17,7 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; import { + getSwipeReplyActiveOffsetX, resolveLongPressFeedback, shouldStartReplyFromSwipe, SWIPE_REPLY_DISTANCE, @@ -125,7 +126,7 @@ function MessageBubbleComponent({ // eslint-disable-next-line new-cap -- RNGH's gesture builder API is Gesture.Pan(). const swipeGesture = Gesture.Pan() - .activeOffsetX([-12, 12]) + .activeOffsetX(getSwipeReplyActiveOffsetX()) .onUpdate(event => { if (!canSwipeReply) { return; diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts index d35a8d2fb2..93e1092e7f 100644 --- a/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { resolveLongPressFeedback, shouldStartReplyFromSwipe } from './message-gesture-state'; +import { + getSwipeReplyActiveOffsetX, + resolveLongPressFeedback, + shouldStartReplyFromSwipe, +} from './message-gesture-state'; + +describe('getSwipeReplyActiveOffsetX', () => { + it('activates the message gesture only for left swipes', () => { + expect(getSwipeReplyActiveOffsetX()).toEqual([-12, Number.MAX_SAFE_INTEGER]); + }); +}); describe('shouldStartReplyFromSwipe', () => { it('starts reply on a committed left swipe when reply is available', () => { diff --git a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts index b216c9b031..17d9b3bf43 100644 --- a/apps/mobile/src/components/kilo-chat/message-gesture-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-gesture-state.ts @@ -2,6 +2,7 @@ export const SWIPE_REPLY_DISTANCE = 56; export const SWIPE_REPLY_FAST_DISTANCE = 24; export const SWIPE_REPLY_FAST_VELOCITY = -650; export const SWIPE_REPLY_MAX_TRANSLATE = 72; +export const SWIPE_REPLY_ACTIVATION_DISTANCE = 12; export const LONG_PRESS_FEEDBACK_PRESS_SCALE = 0.985; export const LONG_PRESS_FEEDBACK_ACTIVE_SCALE = 0.97; export const LONG_PRESS_FEEDBACK_HIGHLIGHT_OPACITY = 1; @@ -22,6 +23,10 @@ type LongPressFeedback = { highlightOpacity: number; }; +export function getSwipeReplyActiveOffsetX(): [number, number] { + return [-SWIPE_REPLY_ACTIVATION_DISTANCE, Number.MAX_SAFE_INTEGER]; +} + export function shouldStartReplyFromSwipe({ canReply, translationX, From b1f237b1a18c2169094d38ba602f28913710ccd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 11:57:21 +0200 Subject: [PATCH 277/289] docs(kiloclaw): note approval ownership invariant --- services/kiloclaw/plugins/kilo-chat/src/approval.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/kiloclaw/plugins/kilo-chat/src/approval.ts b/services/kiloclaw/plugins/kilo-chat/src/approval.ts index a3c2ab3e9d..0d0bf66a88 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/approval.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/approval.ts @@ -214,6 +214,9 @@ export function createKiloChatApprovalCapability(): ChannelApprovalCapability { // This callback is intentionally permissive because the Worker is the trust // boundary, and KiloClaw currently keeps bot-created approval conversations // owner-only by not forwarding additionalMembers. + // If kilo-chat ever supports more than the owner plus the bot in a + // conversation, gate this on session ownership before relaxing those + // owner-only conversation constraints. authorizeActorAction: () => ({ authorized: true }), getActionAvailabilityState: () => ({ kind: 'enabled' as const }), From 1a4090b2bfb5f6afec34c2197fe63c674fe586bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 11:58:13 +0200 Subject: [PATCH 278/289] docs(kiloclaw): explain resolved approval skip --- services/kiloclaw/plugins/kilo-chat/src/approval.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/kiloclaw/plugins/kilo-chat/src/approval.ts b/services/kiloclaw/plugins/kilo-chat/src/approval.ts index 0d0bf66a88..02b8f2eedd 100644 --- a/services/kiloclaw/plugins/kilo-chat/src/approval.ts +++ b/services/kiloclaw/plugins/kilo-chat/src/approval.ts @@ -187,6 +187,8 @@ const nativeRuntime: ChannelApprovalNativeRuntimeAdapter< }, updateEntry: async ({ entry, payload }) => { + // Resolved action blocks are output-only: create/edit routes reject + // `resolved` actions, and /execute-action owns the state transition. if (hasResolvedActionsBlock(payload)) return; const client = makeClient(); From a95d938233286b936995b3c90430252ceb8a9951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 12:00:38 +0200 Subject: [PATCH 279/289] fix(kiloclaw): validate webhook sandbox ids --- services/kiloclaw/src/index.test.ts | 33 +++++++++++++++++++++++++++++ services/kiloclaw/src/index.ts | 5 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/services/kiloclaw/src/index.test.ts b/services/kiloclaw/src/index.test.ts index 7c13c4ee8c..f65ae4217b 100644 --- a/services/kiloclaw/src/index.test.ts +++ b/services/kiloclaw/src/index.test.ts @@ -309,6 +309,39 @@ describe('kilo-chat webhook delivery', () => { expect(init.headers.get('fly-force-instance-id')).toBe('machine-1'); expect(init.headers.get('content-type')).toBe('application/json'); }); + + it('rejects targetBotId suffixes that are not valid sandboxIds before routing', async () => { + for (const targetBotId of ['bot:kiloclaw:', 'bot:kiloclaw:bad$sandbox']) { + const registryStub = { listInstances: vi.fn().mockResolvedValue([]) }; + const registryNamespace = { + idFromName: vi.fn().mockReturnValue('registry-id'), + get: vi.fn().mockReturnValue(registryStub), + }; + const instanceNamespace = { + idFromName: vi.fn().mockReturnValue('instance-id'), + get: vi.fn(), + }; + + const worker = new WorkerEntrypoint( + { + KILOCLAW_INSTANCE: instanceNamespace, + KILOCLAW_REGISTRY: registryNamespace, + GATEWAY_TOKEN_SECRET: 'gateway-secret', + } as never, + {} as never + ); + + await expect( + worker.deliverChatWebhook({ + type: 'bot.status_request', + targetBotId, + }) + ).rejects.toThrow(/Invalid sandboxId derived from targetBotId/); + + expect(registryNamespace.idFromName).not.toHaveBeenCalled(); + expect(instanceNamespace.idFromName).not.toHaveBeenCalled(); + } + }); }); describe('proxy routing target usage', () => { diff --git a/services/kiloclaw/src/index.ts b/services/kiloclaw/src/index.ts index 5c43a527cd..b2d42f0bfa 100644 --- a/services/kiloclaw/src/index.ts +++ b/services/kiloclaw/src/index.ts @@ -16,7 +16,7 @@ import type { Context, Next } from 'hono'; import { Hono } from 'hono'; import { getCookie, deleteCookie } from 'hono/cookie'; import type { z } from 'zod'; -import type { chatWebhookSchema } from '@kilocode/kilo-chat'; +import { sandboxIdSchema, type chatWebhookSchema } from '@kilocode/kilo-chat'; import type { AppEnv, KiloClawEnv, ChatWebhookPayload } from './types'; import type { SnapshotRestoreMessage } from './schemas/snapshot-restore'; @@ -1086,6 +1086,9 @@ export default class extends WorkerEntrypoint { throw new Error(`Invalid targetBotId: ${targetBotId}`); } const sandboxId = targetBotId.slice(botPrefix.length); + if (!sandboxIdSchema.safeParse(sandboxId).success) { + throw new Error(`Invalid sandboxId derived from targetBotId: ${targetBotId}`); + } const { doKey, label } = await this.resolveChatWebhookDoKey(sandboxId); const getWebhookStub = () => From 9c429b9f5a830e1ea62443ddfa3860deba4e5d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 13:40:52 +0200 Subject: [PATCH 280/289] fix(mobile): align kiloclaw tab content spacing --- apps/mobile/src/components/kiloclaw/instance-list-screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 6395ffa674..e69c3b7e96 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -90,7 +90,7 @@ export function InstanceListScreen({ } > From d07286fa25d3d788af2ec585060c2d847f78716a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 15:31:15 +0200 Subject: [PATCH 281/289] fix(dev): skip kiloclaw tunnels for docker-local --- dev/local/scripts/start-tunnel.ts | 50 ++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/dev/local/scripts/start-tunnel.ts b/dev/local/scripts/start-tunnel.ts index f8699c42f9..933009f74d 100644 --- a/dev/local/scripts/start-tunnel.ts +++ b/dev/local/scripts/start-tunnel.ts @@ -11,6 +11,9 @@ type TunnelConfig = { tunnelHostname: string; }; +const DOCKER_HOST_INTERNAL = 'host.docker.internal'; +const DOCKER_LOCAL_PROVIDER = 'docker-local'; + function parseConfFile(filePath: string): Record { if (!fs.existsSync(filePath)) return {}; const result: Record = {}; @@ -61,6 +64,10 @@ function updateEnvValue(filePath: string, key: string, value: string): void { fs.writeFileSync(filePath, content); } +function loadKiloClawProvider(): string { + return parseConfFile(devVarsPath)['KILOCLAW_DEFAULT_PROVIDER'] ?? DOCKER_LOCAL_PROVIDER; +} + function prefixAndWrite(label: string, chunk: Buffer): void { const text = chunk.toString(); const lines = text.split('\n'); @@ -71,17 +78,11 @@ function prefixAndWrite(label: string, chunk: Buffer): void { } } -if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { - console.error( - 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' - ); - process.exit(1); -} - const port = process.argv[2] ?? '3000'; const controllerPort = process.argv[3] ?? '8795'; const kiloChatPort = process.argv[4] ?? '8808'; const config = loadTunnelConfig(); +const provider = loadKiloClawProvider(); const children: Array<{ label: string; child: ReturnType }> = []; @@ -139,7 +140,31 @@ function startQuickTunnel(options: { child.on('close', code => exitAndStopOthers(label, code)); } -if (config.tunnelName) { +if (provider === DOCKER_LOCAL_PROVIDER) { + const apiUrl = `http://${DOCKER_HOST_INTERNAL}:${port}/api/gateway/`; + const checkinUrl = `http://${DOCKER_HOST_INTERNAL}:${controllerPort}/api/controller/checkin`; + const kiloChatUrl = `http://${DOCKER_HOST_INTERNAL}:${kiloChatPort}`; + + updateEnvValue(devVarsPath, 'KILOCODE_API_BASE_URL', apiUrl); + updateEnvValue(devVarsPath, 'KILOCLAW_CHECKIN_URL', checkinUrl); + updateEnvValue(devVarsPath, 'KILOCHAT_BASE_URL', kiloChatUrl); + + console.log('Docker-local provider detected; skipping Cloudflare quick tunnels.'); + console.log(`Set KILOCODE_API_BASE_URL=${apiUrl}`); + console.log(`Set KILOCLAW_CHECKIN_URL=${checkinUrl}`); + console.log(`Set KILOCHAT_BASE_URL=${kiloChatUrl}`); + + setInterval(() => undefined, 60_000); +} else { + if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { + console.error( + 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' + ); + process.exit(1); + } +} + +if (provider !== DOCKER_LOCAL_PROVIDER && config.tunnelName) { const label = 'kiloclaw-tunnel'; const child = spawn('cloudflared', ['tunnel', 'run', config.tunnelName], { stdio: ['ignore', 'pipe', 'pipe'], @@ -163,7 +188,7 @@ if (config.tunnelName) { child.stdout.on('data', data => prefixAndWrite(label, data)); child.stderr.on('data', data => prefixAndWrite(label, data)); child.on('close', code => exitAndStopOthers(label, code)); -} else { +} else if (provider !== DOCKER_LOCAL_PROVIDER) { startQuickTunnel({ label: 'gateway', localPort: port, @@ -198,5 +223,10 @@ if (config.tunnelName) { } for (const signal of ['SIGINT', 'SIGTERM'] as const) { - process.on(signal, () => stopAllChildren(signal)); + process.on(signal, () => { + stopAllChildren(signal); + if (children.length === 0) { + process.exit(0); + } + }); } From b04fe8c5d1542a99db5b576cf07ee6f5c194de44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 15:34:57 +0200 Subject: [PATCH 282/289] chore(dev): add mobile dev target --- dev/local/cli.ts | 2 +- dev/local/services.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/dev/local/cli.ts b/dev/local/cli.ts index b599f60c8c..94de9925ee 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -550,7 +550,7 @@ Usage: dev:env --check Validate env vars (CI mode) dev:env -y Sync without confirmation -Targets: app, app-builder, agents, all, or any service/group name +Targets: app, app-builder, agents, mobile, all, or any service/group name Multiple targets can be specified: dev:start kiloclaw agents`); } diff --git a/dev/local/services.ts b/dev/local/services.ts index 8150c6fe25..9cefdfce29 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -41,6 +41,7 @@ const groups: ServiceGroup[] = [ { id: 'auto-fix', label: 'Auto Fix', alwaysOn: false, groupDependsOn: ['cloud-agent'] }, { id: 'deploy', label: 'Deploy', alwaysOn: false }, { id: 'observability', label: 'Observability', alwaysOn: false }, + { id: 'mobile', label: 'Mobile', alwaysOn: false, sectionBreakBefore: true }, { id: 'storybook', label: 'Storybook', alwaysOn: false, sectionBreakBefore: true }, ]; @@ -178,6 +179,8 @@ const serviceMeta: Record = { dir: 'services/ai-attribution', }, grafana: { group: 'observability', dependsOn: [] }, + // mobile + mobile: { group: 'mobile', dependsOn: [], dir: 'apps/mobile' }, // storybook storybook: { group: 'storybook', dependsOn: [] }, // gastown @@ -324,6 +327,20 @@ function buildServiceDefs(): ServiceDef[] { continue; } + if (name === 'mobile') { + const port = 8081 + portOffset; + defs.push({ + name, + type: 'process', + dir: 'apps/mobile', + port, + dependsOn: meta.dependsOn, + command: ['pnpm', 'run', 'start', '--', '--port', String(port)], + group: meta.group, + }); + continue; + } + if (name in INFRA_PORTS) { defs.push({ name, From 80e8244315cfdc95a5f21d981600c39daf488919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 15:38:15 +0200 Subject: [PATCH 283/289] fix(mobile): preserve chat scroll on keyboard open --- .../message-list-keyboard-scroll.test.ts | 34 +++++++++++++++ .../kilo-chat/message-list-keyboard-scroll.ts | 42 +++++++++++++++++++ .../src/components/kilo-chat/message-list.tsx | 37 ++++++++++++++-- 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts create mode 100644 apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts diff --git a/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts new file mode 100644 index 0000000000..a67f3ddf14 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createMessageListKeyboardScrollScheduler, + MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS, +} from './message-list-keyboard-scroll'; + +describe('message list keyboard scroll scheduler', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('maintains the current viewport by shifting the offset by keyboard height', () => { + vi.useFakeTimers(); + const calls: { animated: boolean; offset: number }[] = []; + const scheduler = createMessageListKeyboardScrollScheduler({ + getScrollOffset: () => 240, + scrollToOffset: params => { + calls.push(params); + }, + }); + + scheduler.schedule(320); + + expect(calls).toEqual([{ animated: true, offset: 560 }]); + + vi.advanceTimersByTime(MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + + expect(calls).toEqual([ + { animated: true, offset: 560 }, + { animated: true, offset: 560 }, + ]); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts new file mode 100644 index 0000000000..ca5868cbb4 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list-keyboard-scroll.ts @@ -0,0 +1,42 @@ +export const MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS = 80; + +type ScrollToOffsetParams = { + animated: boolean; + offset: number; +}; + +type MessageListKeyboardScrollSchedulerParams = { + getScrollOffset: () => number; + scrollToOffset: (params: ScrollToOffsetParams) => void; +}; + +export function createMessageListKeyboardScrollScheduler({ + getScrollOffset, + scrollToOffset, +}: MessageListKeyboardScrollSchedulerParams) { + let retryTimeout: ReturnType | null = null; + + const clearRetry = () => { + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + }; + + const scrollToMaintainedPosition = (offset: number) => { + scrollToOffset({ animated: true, offset }); + }; + + return { + cancel: clearRetry, + schedule: (keyboardHeight: number) => { + clearRetry(); + const maintainedOffset = getScrollOffset() + keyboardHeight; + scrollToMaintainedPosition(maintainedOffset); + retryTimeout = setTimeout(() => { + retryTimeout = null; + scrollToMaintainedPosition(maintainedOffset); + }, MESSAGE_LIST_KEYBOARD_SCROLL_RETRY_DELAY_MS); + }, + }; +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx index 8a677c530b..3bf0b0100a 100644 --- a/apps/mobile/src/components/kilo-chat/message-list.tsx +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -1,11 +1,12 @@ -import { FlashList } from '@shopify/flash-list'; +import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; import { type PendingAction, pendingActionGroupIdForMessage } from '@kilocode/kilo-chat-hooks'; -import { useMemo } from 'react'; -import { View } from 'react-native'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Keyboard, type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native'; import { MessageBubble } from '@/components/kilo-chat/message-bubble'; import { Skeleton } from '@/components/ui/skeleton'; +import { createMessageListKeyboardScrollScheduler } from './message-list-keyboard-scroll'; import { type MessageAuthorMember, resolveMessageAuthorLabel } from './message-presentation'; type Props = { @@ -35,6 +36,18 @@ export function MessageList({ onLongPressMessage, onSwipeReplyMessage, }: Props) { + const listRef = useRef>(null); + const scrollOffsetRef = useRef(0); + const keyboardScrollScheduler = useMemo( + () => + createMessageListKeyboardScrollScheduler({ + getScrollOffset: () => scrollOffsetRef.current, + scrollToOffset: params => { + listRef.current?.scrollToOffset(params); + }, + }), + [] + ); // useMessages returns messages oldest-to-newest. // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition // with startRenderingFromBottom, which expects chronological order. @@ -44,8 +57,24 @@ export function MessageList({ [chronological] ); + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); + + useEffect(() => { + const subscription = Keyboard.addListener('keyboardDidShow', event => { + keyboardScrollScheduler.schedule(event.endCoordinates.height); + }); + + return () => { + subscription.remove(); + keyboardScrollScheduler.cancel(); + }; + }, [keyboardScrollScheduler]); + return ( { // In chronological order, the previous message in time is data[index - 1]. @@ -75,6 +104,8 @@ export function MessageList({ ); }} keyExtractor={item => item.id} + onScroll={handleScroll} + scrollEventThrottle={16} onStartReached={fetchOlder} onStartReachedThreshold={0.5} maintainVisibleContentPosition={{ From e2ddf797479ba25929e8d35081b975f8d39f5fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 5 May 2026 15:49:46 +0200 Subject: [PATCH 284/289] fix(mobile): reuse kiloclaw cards on claw tab --- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 6 ++ .../src/components/home/home-screen.tsx | 2 +- .../instance-card.tsx} | 97 +++++++++++++------ .../components/kiloclaw/instance-list-row.tsx | 67 ------------- .../kiloclaw/instance-list-screen.tsx | 35 ++++++- 5 files changed, 102 insertions(+), 105 deletions(-) rename apps/mobile/src/components/{home/kiloclaw-card.tsx => kiloclaw/instance-card.tsx} (53%) delete mode 100644 apps/mobile/src/components/kiloclaw/instance-list-row.tsx diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx index c0932542dc..ee36b6f1ab 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/index.tsx @@ -14,6 +14,7 @@ import { useForegroundInvalidateKiloclawState } from '@/lib/hooks/use-foreground import { useAllKiloClawInstances } from '@/lib/hooks/use-instance-context'; import { useKiloClawMobileOnboardingState } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useUnreadCounts } from '@/lib/hooks/use-unread-counts'; import { chatSandboxPath } from '@/lib/kilo-chat-routes'; export default function KiloClawTab() { @@ -22,6 +23,7 @@ export default function KiloClawTab() { const [manualRefreshing, setManualRefreshing] = useState(false); const instancesQuery = useAllKiloClawInstances(); const { data: instances } = instancesQuery; + const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts(); const refetchInstances = instancesQuery.refetch; const entryDecision = getKiloClawEntryDecision(instances); const onboardingQuery = useKiloClawMobileOnboardingState(entryDecision.kind === 'empty'); @@ -72,6 +74,10 @@ export default function KiloClawTab() { onSelect={sandboxId => { router.push(chatSandboxPath(sandboxId)); }} + onSettingsPress={sandboxId => { + router.push(`/(app)/kiloclaw/${sandboxId}/dashboard` as Href); + }} + unreadByBadgeBucket={unreadByBadgeBucket} onCreate={() => { router.push('/(app)/onboarding' as Href); }} diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index 0d7976204b..72e45f70d3 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -9,10 +9,10 @@ import { badgeBucketForInstance } from '@kilocode/notifications'; import { AgentSessionsSection } from '@/components/home/agent-sessions-section'; import { AgentsPromoCard } from '@/components/home/agents-promo-card'; import { buildTimedGreeting, Greeting } from '@/components/home/greeting'; -import { KiloClawCard } from '@/components/home/kiloclaw-card'; import { KiloClawPromoCard } from '@/components/home/kiloclaw-promo-card'; import { NewTaskButton } from '@/components/home/new-task-button'; import { SectionHeader } from '@/components/home/section-header'; +import { KiloClawCard } from '@/components/kiloclaw/instance-card'; import { isTransitionalStatus } from '@/components/kiloclaw/status-badge'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; diff --git a/apps/mobile/src/components/home/kiloclaw-card.tsx b/apps/mobile/src/components/kiloclaw/instance-card.tsx similarity index 53% rename from apps/mobile/src/components/home/kiloclaw-card.tsx rename to apps/mobile/src/components/kiloclaw/instance-card.tsx index 299df0f75e..2cc02ca4d5 100644 --- a/apps/mobile/src/components/home/kiloclaw-card.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-card.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; +import { Settings2 } from 'lucide-react-native'; import { Pressable, View } from 'react-native'; import { isTransitionalStatus, statusLabel, statusTone } from '@/components/kiloclaw/status-badge'; @@ -7,17 +8,22 @@ import { StatusDot } from '@/components/ui/status-dot'; import { Text } from '@/components/ui/text'; import { agentColor } from '@/lib/agent-color'; import { useKiloClawStatus, useKiloClawStatusQueryKey } from '@/lib/hooks/use-kiloclaw-queries'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { chatSandboxPath } from '@/lib/kilo-chat-routes'; type KiloClawCardProps = { instance: { sandboxId: string; name: string | null; + botName?: string | null; + botEmoji?: string | null; organizationId: string | null; organizationName: string | null; status: string | null; }; unreadCount?: number; + onPress?: (sandboxId: string) => void; + onSettingsPress?: (sandboxId: string) => void; }; type CachedStatus = NonNullable['data']>; @@ -31,8 +37,14 @@ function firstLetter(name: string): string { return trimmed.length > 0 ? (trimmed[0]?.toUpperCase() ?? 'K') : 'K'; } -export function KiloClawCard({ instance, unreadCount = 0 }: Readonly) { +export function KiloClawCard({ + instance, + unreadCount = 0, + onPress, + onSettingsPress, +}: Readonly) { const router = useRouter(); + const colors = useThemeColors(); // Peek at the latest cached status (non-subscribing) so we can choose the // poll cadence before subscribing. Falls back to the list's status when @@ -50,8 +62,8 @@ export function KiloClawCard({ instance, unreadCount = 0 }: Readonly { + if (onPress) { + onPress(instance.sandboxId); + return; + } router.push(chatSandboxPath(instance.sandboxId)); }; + const handleSettingsPress = () => { + onSettingsPress?.(instance.sandboxId); + }; + return ( - + - - {botEmoji ? ( - {botEmoji} - ) : ( - - {firstLetter(displayName)} - - )} - - - - - {displayName} - + + {botEmoji ? ( + {botEmoji} + ) : ( + + {firstLetter(displayName)} + + )} - - - {label} + + + + {displayName} + + + + + {label} + - + {hasUnread ? ( @@ -109,7 +131,18 @@ export function KiloClawCard({ instance, unreadCount = 0 }: Readonly ) : null} + {onSettingsPress ? ( + + + + ) : null} - + ); } diff --git a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx b/apps/mobile/src/components/kiloclaw/instance-list-row.tsx deleted file mode 100644 index 9e592c445c..0000000000 --- a/apps/mobile/src/components/kiloclaw/instance-list-row.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ChevronRight } from 'lucide-react-native'; -import { Pressable, View } from 'react-native'; - -import { StatusBadge } from '@/components/kiloclaw/status-badge'; -import { Text } from '@/components/ui/text'; -import { agentColor } from '@/lib/agent-color'; -import { type ClawInstance } from '@/lib/hooks/use-instance-context'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -type InstanceListRowProps = { - instance: ClawInstance; - onPress: (sandboxId: string) => void; -}; - -function instanceTitle(instance: ClawInstance): string { - return instance.botName ?? instance.name ?? 'KiloClaw'; -} - -function instanceSubtitle(instance: ClawInstance): string { - return instance.organizationName ?? 'Personal'; -} - -export function InstanceListRow({ instance, onPress }: Readonly) { - const colors = useThemeColors(); - const title = instanceTitle(instance); - const hue = agentColor(title); - - return ( - { - onPress(instance.sandboxId); - }} - className="min-h-16 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 active:opacity-80" - > - - {instance.botEmoji ? ( - {instance.botEmoji} - ) : ( - - {title.trim()[0]?.toUpperCase() ?? 'K'} - - )} - - - - - {title} - - - - - {instanceSubtitle(instance)} - - - - - - - ); -} diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index e69c3b7e96..37509f680f 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -3,7 +3,9 @@ import { Plus } from 'lucide-react-native'; import { RefreshControl, ScrollView, View } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; -import { InstanceListRow } from '@/components/kiloclaw/instance-list-row'; +import { badgeBucketForInstance } from '@kilocode/notifications'; + +import { KiloClawCard } from '@/components/kiloclaw/instance-card'; import { ProfileAvatarButton } from '@/components/profile-avatar-button'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; @@ -15,9 +17,11 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type Props = { instances: ClawInstance[]; onSelect: (sandboxId: string) => void; + onSettingsPress: (sandboxId: string) => void; onCreate: () => void; refreshing: boolean; onRefresh: () => void; + unreadByBadgeBucket?: Map; showSectionCounts?: boolean; }; @@ -32,11 +36,15 @@ function InstanceSection({ title, instances, onSelect, + onSettingsPress, + unreadByBadgeBucket, showCount, }: Readonly<{ title: string; instances: ClawInstance[]; onSelect: (sandboxId: string) => void; + onSettingsPress: (sandboxId: string) => void; + unreadByBadgeBucket?: Map; showCount: boolean; }>) { if (instances.length === 0) { @@ -45,7 +53,7 @@ function InstanceSection({ return ( - + {title} {showCount ? ( @@ -55,7 +63,13 @@ function InstanceSection({ {instances.map(instance => ( - + ))} @@ -65,9 +79,11 @@ function InstanceSection({ export function InstanceListScreen({ instances, onSelect, + onSettingsPress, onCreate, refreshing, onRefresh, + unreadByBadgeBucket, showSectionCounts = false, }: Readonly) { const colors = useThemeColors(); @@ -78,6 +94,11 @@ export function InstanceListScreen({ onSelect(sandboxId); } + function handleSettingsPress(sandboxId: string) { + void Haptics.selectionAsync(); + onSettingsPress(sandboxId); + } + return ( } > {instances.length === 0 ? ( - +