From 3ed7b94b938506e2502a7b69f0e9de25c5bb5b5d 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:28:35 +0200 Subject: [PATCH 01/88] refactor(db): rename channel_badge_counts to badge_counts (general purpose); update all consumers --- apps/web/src/routers/user-router.test.ts | 14 +++++----- apps/web/src/routers/user-router.ts | 26 +++++++++--------- packages/db/src/schema.ts | 27 ++++++++++--------- .../src/dos/NotificationChannelDO.ts | 16 +++++------ 4 files changed, 40 insertions(+), 43 deletions(-) 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/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/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index a58bfda8bc..f01c2ae30a 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,6 +1,6 @@ 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 { and, eq, inArray, isNull, sql, sum } from 'drizzle-orm'; import type { Event } from 'stream-chat'; @@ -140,17 +140,17 @@ export class NotificationChannelDO extends DurableObject { // temporarily has no registered push tokens (e.g. between reinstalls). // Uses UPSERT so the row is created on first notification for this channel. 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: sandboxId, 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 e8d062c6f3d82aeab6ff0fda9e57ffaaf318df68 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:29:25 +0200 Subject: [PATCH 02/88] feat(db): migration to rename badge_counts and reset rows --- .../src/migrations/0107_dapper_power_pack.sql | 9 + .../db/src/migrations/meta/0107_snapshot.json | 18016 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + 3 files changed, 18032 insertions(+) create mode 100644 packages/db/src/migrations/0107_dapper_power_pack.sql create mode 100644 packages/db/src/migrations/meta/0107_snapshot.json 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 From 20b9b3b79153fda3bae87e15fb3182f69b6fd49c 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:39:40 +0200 Subject: [PATCH 03/88] 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. --- packages/notifications/src/badge-buckets.ts | 11 +++++++++++ packages/notifications/src/index.ts | 1 + pnpm-lock.yaml | 3 +++ services/notifications/package.json | 1 + .../notifications/src/dos/NotificationChannelDO.ts | 8 +++++--- 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 packages/notifications/src/badge-buckets.ts 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 f01c2ae30a..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 { 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,13 +136,14 @@ 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(badge_counts) - .values({ user_id: instance.user_id, badge_bucket: sandboxId, badge_count: 1 }) + .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` }, From 1bb97c6cced385a38f988e606581f196c023adc0 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:55:15 +0200 Subject: [PATCH 04/88] chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API_SECRET --- services/notifications/src/bindings.d.ts | 3 + .../notifications/worker-configuration.d.ts | 578 ++---------------- services/notifications/wrangler.jsonc | 10 +- 3 files changed, 61 insertions(+), 530 deletions(-) create mode 100644 services/notifications/src/bindings.d.ts diff --git a/services/notifications/src/bindings.d.ts b/services/notifications/src/bindings.d.ts new file mode 100644 index 0000000000..40e773ded4 --- /dev/null +++ b/services/notifications/src/bindings.d.ts @@ -0,0 +1,3 @@ +import type {} from './worker-configuration.d.ts'; + +export type NotificationsEnv = Env; diff --git a/services/notifications/worker-configuration.d.ts b/services/notifications/worker-configuration.d.ts index cbc9201506..6a3ebcce40 100644 --- a/services/notifications/worker-configuration.d.ts +++ b/services/notifications/worker-configuration.d.ts @@ -1,17 +1,17 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b336c1c1e874405e99f5e26c8c9319df) -// Runtime types generated with workerd@1.20260312.1 2026-02-01 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 0819ae5a59d68074ad7e23557cbc10b7) +// Runtime types generated with workerd@1.20251217.0 2026-02-01 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); durableNamespaces: "NotificationChannelDO"; } interface Env { - HYPERDRIVE: Hyperdrive; - RECEIPTS_QUEUE: Queue; - STREAM_CHAT_API_SECRET: SecretsStoreSecret; + NOTIFICATION_CHANNEL_DO: DurableObjectNamespace; EXPO_ACCESS_TOKEN: SecretsStoreSecret; - NOTIFICATION_CHANNEL_DO: DurableObjectNamespace /* NotificationChannelDO */; + EVENT_SERVICE: Fetcher /* event-service */; + RECEIPTS_QUEUE: Queue; + HYPERDRIVE: Hyperdrive; } } interface Env extends Cloudflare.Env {} @@ -439,22 +439,22 @@ interface ExecutionContext { readonly exports: Cloudflare.Exports; readonly props: Props; } -type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; -type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; -type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; -interface ExportedHandler { - fetch?: ExportedHandlerFetchHandler; - tail?: ExportedHandlerTailHandler; - trace?: ExportedHandlerTraceHandler; - tailStream?: ExportedHandlerTailStreamHandler; - scheduled?: ExportedHandlerScheduledHandler; - test?: ExportedHandlerTestHandler; - email?: EmailExportedHandler; - queue?: ExportedHandlerQueueHandler; +type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; +type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; } interface StructuredSerializeOptions { transfer?: any[]; @@ -502,10 +502,8 @@ interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; -type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; - routingMode?: DurableObjectRoutingMode; } interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { } @@ -1393,12 +1391,6 @@ declare abstract class PromiseRejectionEvent extends Event { */ declare class FormData { constructor(); - /** - * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) - */ - append(name: string, value: string | Blob): void; /** * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * @@ -1435,12 +1427,6 @@ declare class FormData { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) */ has(name: string): boolean; - /** - * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) - */ - set(name: string, value: string | Blob): void; /** * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * @@ -1767,7 +1753,7 @@ interface Request> e * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) */ signal: AbortSignal; - cf?: Cf; + cf: Cf | undefined; /** * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. * @@ -2102,8 +2088,6 @@ interface Transformer { expectedLength?: number; } interface StreamPipeOptions { - preventAbort?: boolean; - preventCancel?: boolean; /** * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. * @@ -2122,6 +2106,8 @@ interface StreamPipeOptions { * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. */ preventClose?: boolean; + preventAbort?: boolean; + preventCancel?: boolean; signal?: AbortSignal; } type ReadableStreamReadResult = { @@ -2396,13 +2382,13 @@ declare abstract class TransformStreamDefaultController { terminate(): void; } interface ReadableWritablePair { - readable: ReadableStream; /** * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. * * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. */ writable: WritableStream; + readable: ReadableStream; } /** * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. @@ -3050,7 +3036,7 @@ declare var WebSocket: { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) */ interface WebSocket extends EventTarget { - accept(options?: WebSocketAcceptOptions): void; + accept(): void; /** * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. * @@ -3089,22 +3075,6 @@ interface WebSocket extends EventTarget { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) */ extensions: string | null; - /** - * The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) - */ - binaryType: "blob" | "arraybuffer"; -} -interface WebSocketAcceptOptions { - /** - * When set to `true`, receiving a server-initiated WebSocket Close frame will not - * automatically send a reciprocal Close frame, leaving the connection in a half-open - * state. This is useful for proxying scenarios where you need to coordinate closing - * both sides independently. Defaults to `false` when the - * `no_web_socket_half_open_by_default` compatibility flag is enabled. - */ - allowHalfOpen?: boolean; } declare const WebSocketPair: { new (): { @@ -3223,8 +3193,6 @@ interface Container { signal(signo: number): void; getTcpPort(port: number): Fetcher; setInactivityTimeout(durationMs: number | bigint): Promise; - interceptOutboundHttp(addr: string, binding: Fetcher): Promise; - interceptAllOutboundHttp(binding: Fetcher): Promise; } interface ContainerStartupOptions { entrypoint?: string[]; @@ -3350,181 +3318,6 @@ declare abstract class Performance { get timeOrigin(): number; /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ now(): number; - /** - * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) - */ - toJSON(): object; -} -// AI Search V2 API Error Interfaces -interface AiSearchInternalError extends Error { -} -interface AiSearchNotFoundError extends Error { -} -interface AiSearchNameNotSetError extends Error { -} -// AI Search V2 Request Types -type AiSearchSearchRequest = { - messages: Array<{ - role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; - content: string | null; - }>; - ai_search_options?: { - retrieval?: { - retrieval_type?: 'vector' | 'keyword' | 'hybrid'; - /** Match threshold (0-1, default 0.4) */ - match_threshold?: number; - /** Maximum number of results (1-50, default 10) */ - max_num_results?: number; - filters?: VectorizeVectorMetadataFilter; - /** Context expansion (0-3, default 0) */ - context_expansion?: number; - [key: string]: unknown; - }; - query_rewrite?: { - enabled?: boolean; - model?: string; - rewrite_prompt?: string; - [key: string]: unknown; - }; - reranking?: { - /** Enable reranking (default false) */ - enabled?: boolean; - model?: '@cf/baai/bge-reranker-base' | ''; - /** Match threshold (0-1, default 0.4) */ - match_threshold?: number; - [key: string]: unknown; - }; - [key: string]: unknown; - }; -}; -type AiSearchChatCompletionsRequest = { - messages: Array<{ - role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; - content: string | null; - }>; - model?: string; - stream?: boolean; - ai_search_options?: { - retrieval?: { - retrieval_type?: 'vector' | 'keyword' | 'hybrid'; - match_threshold?: number; - max_num_results?: number; - filters?: VectorizeVectorMetadataFilter; - context_expansion?: number; - [key: string]: unknown; - }; - query_rewrite?: { - enabled?: boolean; - model?: string; - rewrite_prompt?: string; - [key: string]: unknown; - }; - reranking?: { - enabled?: boolean; - model?: '@cf/baai/bge-reranker-base' | ''; - match_threshold?: number; - [key: string]: unknown; - }; - [key: string]: unknown; - }; - [key: string]: unknown; -}; -// AI Search V2 Response Types -type AiSearchSearchResponse = { - search_query: string; - chunks: Array<{ - id: string; - type: string; - /** Match score (0-1) */ - score: number; - text: string; - item: { - timestamp?: number; - key: string; - metadata?: Record; - }; - scoring_details?: { - /** Keyword match score (0-1) */ - keyword_score?: number; - /** Vector similarity score (0-1) */ - vector_score?: number; - }; - }>; -}; -type AiSearchListResponse = Array<{ - id: string; - internal_id?: string; - account_id?: string; - account_tag?: string; - /** Whether the instance is enabled (default true) */ - enable?: boolean; - type?: 'r2' | 'web-crawler'; - source?: string; - [key: string]: unknown; -}>; -type AiSearchConfig = { - /** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */ - id: string; - type: 'r2' | 'web-crawler'; - source: string; - source_params?: object; - /** Token ID (UUID format) */ - token_id?: string; - ai_gateway_id?: string; - /** Enable query rewriting (default false) */ - rewrite_query?: boolean; - /** Enable reranking (default false) */ - reranking?: boolean; - embedding_model?: string; - ai_search_model?: string; -}; -type AiSearchInstance = { - id: string; - enable?: boolean; - type?: 'r2' | 'web-crawler'; - source?: string; - [key: string]: unknown; -}; -// AI Search Instance Service - Instance-level operations -declare abstract class AiSearchInstanceService { - /** - * Search the AI Search instance for relevant chunks. - * @param params Search request with messages and AI search options - * @returns Search response with matching chunks - */ - search(params: AiSearchSearchRequest): Promise; - /** - * Generate chat completions with AI Search context. - * @param params Chat completions request with optional streaming - * @returns Response object (if streaming) or chat completion result - */ - chatCompletions(params: AiSearchChatCompletionsRequest): Promise; - /** - * Delete this AI Search instance. - */ - delete(): Promise; -} -// AI Search Account Service - Account-level operations -declare abstract class AiSearchAccountService { - /** - * List all AI Search instances in the account. - * @returns Array of AI Search instances - */ - list(): Promise; - /** - * Get an AI Search instance by ID. - * @param name Instance ID - * @returns Instance service for performing operations - */ - get(name: string): AiSearchInstanceService; - /** - * Create a new AI Search instance. - * @param config Instance configuration - * @returns Instance service for performing operations - */ - create(config: AiSearchConfig): Promise; } type AiImageClassificationInput = { image: number[]; @@ -5714,7 +5507,7 @@ interface Ai_Cf_Qwen_Qwq_32B_Messages { }; })[]; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -5980,7 +5773,7 @@ interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { }; })[]; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -6071,7 +5864,7 @@ interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { */ prompt: string; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -6230,7 +6023,7 @@ interface Ai_Cf_Google_Gemma_3_12B_It_Messages { }; })[]; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -6502,7 +6295,7 @@ interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { })[]; response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -6732,7 +6525,7 @@ interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { })[]; response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; /** - * JSON schema that should be fulfilled for the response. + * JSON schema that should be fufilled for the response. */ guided_json?: object; /** @@ -7782,7 +7575,7 @@ interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { */ text: string | string[]; /** - * Target language to translate to + * Target langauge to translate to */ target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva"; } @@ -8713,48 +8506,6 @@ type AiModelListType = Record; declare abstract class Ai { aiGatewayLogId: string | null; gateway(gatewayId: string): AiGateway; - /** - * Access the AI Search API for managing AI-powered search instances. - * - * This is the new API that replaces AutoRAG with better namespace separation: - * - Account-level operations: `list()`, `create()` - * - Instance-level operations: `get(id).search()`, `get(id).chatCompletions()`, `get(id).delete()` - * - * @example - * ```typescript - * // List all AI Search instances - * const instances = await env.AI.aiSearch.list(); - * - * // Search an instance - * const results = await env.AI.aiSearch.get('my-search').search({ - * messages: [{ role: 'user', content: 'What is the policy?' }], - * ai_search_options: { - * retrieval: { max_num_results: 10 } - * } - * }); - * - * // Generate chat completions with AI Search context - * const response = await env.AI.aiSearch.get('my-search').chatCompletions({ - * messages: [{ role: 'user', content: 'What is the policy?' }], - * model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast' - * }); - * ``` - */ - aiSearch(): AiSearchAccountService; - /** - * @deprecated AutoRAG has been replaced by AI Search. - * Use `env.AI.aiSearch` instead for better API design and new features. - * - * Migration guide: - * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()` - * - `env.AI.autorag('id').search({ query: '...' })` → `env.AI.aiSearch.get('id').search({ messages: [{ role: 'user', content: '...' }] })` - * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)` - * - * Note: The old API continues to work for backwards compatibility, but new projects should use AI Search. - * - * @see AiSearchAccountService - * @param autoragId Optional instance ID (omit for account-level operations) - */ autorag(autoragId: string): AutoRAG; run(model: Name, inputs: InputOptions, options?: Options): Promise; getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line } -/** - * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchInternalError instead. - * @see AiSearchInternalError - */ interface AutoRAGInternalError extends Error { } -/** - * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNotFoundError instead. - * @see AiSearchNotFoundError - */ interface AutoRAGNotFoundError extends Error { } -/** - * @deprecated This error type is no longer used in the AI Search API. - */ interface AutoRAGUnauthorizedError extends Error { } -/** - * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNameNotSetError instead. - * @see AiSearchNameNotSetError - */ interface AutoRAGNameNotSetError extends Error { } type ComparisonFilter = { @@ -8895,11 +8631,6 @@ type CompoundFilter = { type: 'and' | 'or'; filters: ComparisonFilter[]; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use AiSearchSearchRequest with the new API instead. - * @see AiSearchSearchRequest - */ type AutoRagSearchRequest = { query: string; filters?: CompoundFilter | ComparisonFilter; @@ -8914,28 +8645,13 @@ type AutoRagSearchRequest = { }; rewrite_query?: boolean; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use AiSearchChatCompletionsRequest with the new API instead. - * @see AiSearchChatCompletionsRequest - */ type AutoRagAiSearchRequest = AutoRagSearchRequest & { stream?: boolean; system_prompt?: string; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use AiSearchChatCompletionsRequest with stream: true instead. - * @see AiSearchChatCompletionsRequest - */ type AutoRagAiSearchRequestStreaming = Omit & { stream: true; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use AiSearchSearchResponse with the new API instead. - * @see AiSearchSearchResponse - */ type AutoRagSearchResponse = { object: 'vector_store.search_results.page'; search_query: string; @@ -8952,11 +8668,6 @@ type AutoRagSearchResponse = { has_more: boolean; next_page: string | null; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use AiSearchListResponse with the new API instead. - * @see AiSearchListResponse - */ type AutoRagListResponse = { id: string; enable: boolean; @@ -8966,51 +8677,14 @@ type AutoRagListResponse = { paused: boolean; status: string; }[]; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * The new API returns different response formats for chat completions. - */ type AutoRagAiSearchResponse = AutoRagSearchResponse & { response: string; }; -/** - * @deprecated AutoRAG has been replaced by AI Search. - * Use the new AI Search API instead: `env.AI.aiSearch` - * - * Migration guide: - * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()` - * - `env.AI.autorag('id').search(...)` → `env.AI.aiSearch.get('id').search(...)` - * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)` - * - * @see AiSearchAccountService - * @see AiSearchInstanceService - */ declare abstract class AutoRAG { - /** - * @deprecated Use `env.AI.aiSearch.list()` instead. - * @see AiSearchAccountService.list - */ list(): Promise; - /** - * @deprecated Use `env.AI.aiSearch.get(id).search(...)` instead. - * Note: The new API uses a messages array instead of a query string. - * @see AiSearchInstanceService.search - */ search(params: AutoRagSearchRequest): Promise; - /** - * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. - * @see AiSearchInstanceService.chatCompletions - */ aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; - /** - * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. - * @see AiSearchInstanceService.chatCompletions - */ aiSearch(params: AutoRagAiSearchRequest): Promise; - /** - * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. - * @see AiSearchInstanceService.chatCompletions - */ aiSearch(params: AutoRagAiSearchRequest): Promise; } interface BasicImageTransformations { @@ -9761,10 +9435,6 @@ interface D1Meta { * The region of the database instance that executed the query. */ served_by_region?: string; - /** - * The three letters airport code of the colo that executed the query. - */ - served_by_colo?: string; /** * True if-and-only-if the database instance that executed the query was the primary. */ @@ -9853,15 +9523,6 @@ declare abstract class D1PreparedStatement { // ignored when `Disposable` is included in the standard lib. interface Disposable { } -/** - * The returned data after sending an email - */ -interface EmailSendResult { - /** - * The Email Message ID - */ - messageId: string; -} /** * An email message that can be sent from a Worker. */ @@ -9903,55 +9564,24 @@ interface ForwardableEmailMessage extends EmailMessage { * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). * @returns A promise that resolves when the email message is forwarded. */ - forward(rcptTo: string, headers?: Headers): Promise; + forward(rcptTo: string, headers?: Headers): Promise; /** * Reply to the sender of this email message with a new EmailMessage object. * @param message The reply message. * @returns A promise that resolves when the email message is replied. */ - reply(message: EmailMessage): Promise; -} -/** A file attachment for an email message */ -type EmailAttachment = { - disposition: 'inline'; - contentId: string; - filename: string; - type: string; - content: string | ArrayBuffer | ArrayBufferView; -} | { - disposition: 'attachment'; - contentId?: undefined; - filename: string; - type: string; - content: string | ArrayBuffer | ArrayBufferView; -}; -/** An Email Address */ -interface EmailAddress { - name: string; - email: string; + reply(message: EmailMessage): Promise; } /** * A binding that allows a Worker to send email messages. */ interface SendEmail { - send(message: EmailMessage): Promise; - send(builder: { - from: string | EmailAddress; - to: string | string[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | string[]; - bcc?: string | string[]; - headers?: Record; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise; + send(message: EmailMessage): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; } -declare type EmailExportedHandler = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) => void | Promise; +declare type EmailExportedHandler = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) => void | Promise; declare module "cloudflare:email" { let _EmailMessage: { prototype: EmailMessage; @@ -9979,7 +9609,7 @@ interface Hyperdrive { /** * Connect directly to Hyperdrive as if it's your database, returning a TCP socket. * - * Calling this method returns an identical socket to if you call + * Calling this method returns an idential socket to if you call * `connect("host:port")` using the `host` and `port` fields from this object. * Pick whichever approach works better with your preferred DB client library. * @@ -10092,83 +9722,6 @@ type ImageOutputOptions = { background?: string; anim?: boolean; }; -interface ImageMetadata { - id: string; - filename?: string; - uploaded?: string; - requireSignedURLs: boolean; - meta?: Record; - variants: string[]; - draft?: boolean; - creator?: string; -} -interface ImageUploadOptions { - id?: string; - filename?: string; - requireSignedURLs?: boolean; - metadata?: Record; - creator?: string; - encoding?: 'base64'; -} -interface ImageUpdateOptions { - requireSignedURLs?: boolean; - metadata?: Record; - creator?: string; -} -interface ImageListOptions { - limit?: number; - cursor?: string; - sortOrder?: 'asc' | 'desc'; - creator?: string; -} -interface ImageList { - images: ImageMetadata[]; - cursor?: string; - listComplete: boolean; -} -interface HostedImagesBinding { - /** - * Get detailed metadata for a hosted image - * @param imageId The ID of the image (UUID or custom ID) - * @returns Image metadata, or null if not found - */ - details(imageId: string): Promise; - /** - * Get the raw image data for a hosted image - * @param imageId The ID of the image (UUID or custom ID) - * @returns ReadableStream of image bytes, or null if not found - */ - image(imageId: string): Promise | null>; - /** - * Upload a new hosted image - * @param image The image file to upload - * @param options Upload configuration - * @returns Metadata for the uploaded image - * @throws {@link ImagesError} if upload fails - */ - upload(image: ReadableStream | ArrayBuffer, options?: ImageUploadOptions): Promise; - /** - * Update hosted image metadata - * @param imageId The ID of the image - * @param options Properties to update - * @returns Updated image metadata - * @throws {@link ImagesError} if update fails - */ - update(imageId: string, options: ImageUpdateOptions): Promise; - /** - * Delete a hosted image - * @param imageId The ID of the image - * @returns True if deleted, false if not found - */ - delete(imageId: string): Promise; - /** - * List hosted images with pagination - * @param options List configuration - * @returns List of images with pagination info - * @throws {@link ImagesError} if list fails - */ - list(options?: ImageListOptions): Promise; -} interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -10182,10 +9735,6 @@ interface ImagesBinding { * @returns A transform handle */ input(stream: ReadableStream, options?: ImageInputOptions): ImageTransformer; - /** - * Access hosted images CRUD operations - */ - readonly hosted: HostedImagesBinding; } interface ImageTransformer { /** @@ -10252,13 +9801,7 @@ interface MediaTransformer { * @param transform - Configuration for how the media should be transformed * @returns A generator for producing the transformed media output */ - transform(transform?: MediaTransformationInputOptions): MediaTransformationGenerator; - /** - * Generates the final media output with specified options. - * @param output - Configuration for the output format and parameters - * @returns The final transformation result containing the transformed media - */ - output(output?: MediaTransformationOutputOptions): MediaTransformationResult; + transform(transform: MediaTransformationInputOptions): MediaTransformationGenerator; } /** * Generator for producing media transformation results. @@ -10270,7 +9813,7 @@ interface MediaTransformationGenerator { * @param output - Configuration for the output format and parameters * @returns The final transformation result containing the transformed media */ - output(output?: MediaTransformationOutputOptions): MediaTransformationResult; + output(output: MediaTransformationOutputOptions): MediaTransformationResult; } /** * Result of a media transformation operation. @@ -10279,19 +9822,19 @@ interface MediaTransformationGenerator { interface MediaTransformationResult { /** * Returns the transformed media as a readable stream of bytes. - * @returns A promise containing a readable stream with the transformed media + * @returns A stream containing the transformed media data */ - media(): Promise>; + media(): ReadableStream; /** * Returns the transformed media as an HTTP response object. - * @returns The transformed media as a Promise, ready to store in cache or return to users + * @returns The transformed media as a Response, ready to store in cache or return to users */ - response(): Promise; + response(): Response; /** * Returns the MIME type of the transformed media. - * @returns A promise containing the content type string (e.g., 'image/jpeg', 'video/mp4') + * @returns The content type string (e.g., 'image/jpeg', 'video/mp4') */ - contentType(): Promise; + contentType(): string; } /** * Configuration options for transforming media input. @@ -10399,7 +9942,7 @@ declare module "cloudflare:pipelines" { protected ctx: ExecutionContext; constructor(ctx: ExecutionContext, env: Env); /** - * run receives an array of PipelineRecord which can be + * run recieves an array of PipelineRecord which can be * transformed and returned to the pipeline * @param records Incoming records from the pipeline to be transformed * @param metadata Information about the specific pipeline calling the transformation entrypoint @@ -10670,12 +10213,9 @@ declare namespace CloudflareWorkersModule { timestamp: Date; type: string; }; - export type WorkflowStepContext = { - attempt: number; - }; export abstract class WorkflowStep { - do>(name: string, callback: (ctx: WorkflowStepContext) => Promise): Promise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise): Promise; + do>(name: string, callback: () => Promise): Promise; + do>(name: string, config: WorkflowStepConfig, callback: () => Promise): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -10683,7 +10223,6 @@ declare namespace CloudflareWorkersModule { timeout?: WorkflowTimeoutDuration | number; }): Promise>; } - export type WorkflowInstanceStatus = 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown'; export abstract class WorkflowEntrypoint | unknown = unknown> implements Rpc.WorkflowEntrypointBranded { [Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never; protected ctx: ExecutionContext; @@ -10717,14 +10256,12 @@ type MarkdownDocument = { blob: Blob; }; type ConversionResponse = { - id: string; name: string; mimeType: string; format: 'markdown'; tokens: number; data: string; } | { - id: string; name: string; mimeType: string; format: 'error'; @@ -10742,8 +10279,6 @@ type ConversionOptions = { images?: EmbeddedImageConversionOptions & { convertOGImage?: boolean; }; - hostname?: string; - cssSelector?: string; }; docx?: { images?: EmbeddedImageConversionOptions; @@ -10881,15 +10416,6 @@ declare namespace TailStream { readonly level: "debug" | "error" | "info" | "log" | "warn"; readonly message: object; } - interface DroppedEventsDiagnostic { - readonly diagnosticsType: "droppedEvents"; - readonly count: number; - } - interface StreamDiagnostic { - readonly type: 'streamDiagnostic'; - // To add new diagnostic types, define a new interface and add it to this union type. - readonly diagnostic: DroppedEventsDiagnostic; - } // This marks the worker handler return information. // This is separate from Outcome because the worker invocation can live for a long time after // returning. For example - Websockets that return an http upgrade response but then continue @@ -10906,7 +10432,7 @@ declare namespace TailStream { readonly type: "attributes"; readonly info: Attribute[]; } - type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | StreamDiagnostic | Return | Attributes; + type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | Return | Attributes; // Context in which this trace event lives. interface SpanContext { // Single id for the entire top-level invocation @@ -10920,7 +10446,7 @@ declare namespace TailStream { // For Hibernate and Mark this would be the span under which they were emitted. // spanId is not set ONLY if: // 1. This is an Onset event - // 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) + // 2. We are not inherting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) readonly spanId?: string; } interface TailEvent { 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 d87c0fb99f8c12693613d3f43aa5a6a20cef2d1e 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:58:35 +0200 Subject: [PATCH 05/88] chore(notifications): add vitest scaffold --- services/notifications/package.json | 2 +- services/notifications/src/__tests__/env.d.ts | 3 +++ services/notifications/src/__tests__/setup.ts | 17 ++++++++++++++ services/notifications/vitest.config.mts | 23 +++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 services/notifications/src/__tests__/env.d.ts create mode 100644 services/notifications/src/__tests__/setup.ts diff --git a/services/notifications/package.json b/services/notifications/package.json index a7897326d8..7a6f17769a 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" diff --git a/services/notifications/src/__tests__/env.d.ts b/services/notifications/src/__tests__/env.d.ts new file mode 100644 index 0000000000..1a09f443f6 --- /dev/null +++ b/services/notifications/src/__tests__/env.d.ts @@ -0,0 +1,3 @@ +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} diff --git a/services/notifications/src/__tests__/setup.ts b/services/notifications/src/__tests__/setup.ts new file mode 100644 index 0000000000..ad1be4336a --- /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 === 'channel_badge_counts') return [{ total: 0 }]; + return []; + }, + }), + }), + insert: () => ({ values: () => ({ onConflictDoUpdate: async () => undefined }) }), + delete: () => ({ where: async () => undefined }), + }), +})); 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; } + } + `, + }, + ], + }, }, }, }, From 2a621db8e5ca150f9138d5b5f24c76bc158b7843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:06:04 +0200 Subject: [PATCH 06/88] feat(notifications): rewrite NotificationChannelDO around dispatchPush --- .../src/__tests__/dispatch-push.test.ts | 131 +++++++++ .../src/dos/NotificationChannelDO.ts | 258 ++++++------------ 2 files changed, 211 insertions(+), 178 deletions(-) create mode 100644 services/notifications/src/__tests__/dispatch-push.test.ts 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..29160f8b08 --- /dev/null +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -0,0 +1,131 @@ +import { env } 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: (cols?: unknown) => ({ + 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'); + }); +}); diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index 3568f9a8e7..ee73d09944 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -1,210 +1,112 @@ 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 -}; - -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 }); - } - - 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); - } + async dispatchPush(input: DispatchPushInput): Promise { + // 1. Idempotency. DO is single-threaded, requests for a given conversation + // serialize on this instance. A `failed` outcome does NOT write the + // idempotency key, so the next attempt can retry. + const idemKey = `${IDEM_PREFIX}${input.idempotencyKey}`; + const seen = await this.ctx.storage.get(idemKey); + if (seen) return { kind: 'duplicate' }; + + // 2. Presence + const inContext = await this.env.EVENT_SERVICE.isUserInContext( + input.userId, + input.presenceContext + ); + if (inContext) return { kind: 'suppressed_presence' }; - // 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 (only if badge is set). + let badgeTotal: number | undefined; + if (input.badge) { + 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) { + // Intentionally do NOT write the idempotency key on failure — let + // upstream retry. The DO's single-threading prevents concurrent + // double-sends within the same conversation. + 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. Idempotency write — only after a successful send. + await this.ctx.storage.put(idemKey, Date.now()); + await this.ctx.storage.setAlarm(Date.now() + IDEM_TTL_MS); - private async scheduleAlarm(delayMs: number): Promise { - // Always reset the alarm to the new debounce window - await this.ctx.storage.setAlarm(Date.now() + delayMs); + return { kind: 'delivered', tokenCount: tokens.length }; } -} -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; + override async alarm(): Promise { + const now = Date.now(); + const entries = await this.ctx.storage.list({ prefix: IDEM_PREFIX }); + const expired: string[] = []; + for (const [key, ts] of entries) { + if (now - ts > IDEM_TTL_MS) expired.push(key); + } + if (expired.length > 0) await this.ctx.storage.delete(expired); + } } From 26fccf55c21829da2f32395433cdc0f6eb4d41f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:06:20 +0200 Subject: [PATCH 07/88] chore(notifications): drop orphan badgeBucketForInstance helper --- packages/notifications/src/badge-buckets.ts | 2 -- 1 file changed, 2 deletions(-) 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 7fad8792511f0288dc39bc5742a1e2bc7bbe7616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:11:38 +0200 Subject: [PATCH 08/88] feat(notifications): add sendPushForConversation WorkerEntrypoint RPC --- pnpm-lock.yaml | 41 ++++++++- services/notifications/package.json | 1 + services/notifications/src/__tests__/env.d.ts | 6 +- .../send-push-for-conversation.test.ts | 66 +++++++++++++++ services/notifications/src/index.ts | 83 ++++++++++++++++++- 5 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 services/notifications/src/__tests__/send-push-for-conversation.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00bae11778..40be24ffe2 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 @@ -2008,6 +2008,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 @@ -16744,7 +16747,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 +22255,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 +25686,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 +26356,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 7a6f17769a..05fed40c18 100644 --- a/services/notifications/package.json +++ b/services/notifications/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@kilocode/db": "workspace:*", + "@kilocode/event-service": "workspace:*", "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "catalog:", diff --git a/services/notifications/src/__tests__/env.d.ts b/services/notifications/src/__tests__/env.d.ts index 1a09f443f6..3257351c91 100644 --- a/services/notifications/src/__tests__/env.d.ts +++ b/services/notifications/src/__tests__/env.d.ts @@ -1,3 +1,7 @@ +import type NotificationsService from '../index'; + declare module 'cloudflare:test' { - interface ProvidedEnv extends Env {} + 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..8b993be145 --- /dev/null +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -0,0 +1,66 @@ +import { env } from 'cloudflare:test'; +import { describe, expect, it, vi } from 'vitest'; +import type { + DispatchPushInput, + PerRecipientResult, + SendPushForConversationInput, +} from '@kilocode/notifications'; + +import * 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/index.ts b/services/notifications/src/index.ts index 231df770e7..37dabfd4f9 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -1,14 +1,89 @@ +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 default 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); + } -export default { fetch: app.fetch, queue }; + 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, + }); + } +} From f6e1848f83de60d5d2bd911d99079ef1dd4d7f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:13:02 +0200 Subject: [PATCH 09/88] chore(notifications): delete Stream webhook route --- services/notifications/src/routes/webhooks.ts | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 services/notifications/src/routes/webhooks.ts 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 }; From 3c7c82e483673cd34ea1f5c228b2c7f1bc459377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:13:31 +0200 Subject: [PATCH 10/88] chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:test types --- services/notifications/src/bindings.d.ts | 11 +++++++++++ services/notifications/tsconfig.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/services/notifications/src/bindings.d.ts b/services/notifications/src/bindings.d.ts index 40e773ded4..5797e9eaf4 100644 --- a/services/notifications/src/bindings.d.ts +++ b/services/notifications/src/bindings.d.ts @@ -1,3 +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/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"] From 227b90ea5aa4ed3b290af3b9b245dce8211d6d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:15:48 +0200 Subject: [PATCH 11/88] 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. --- packages/event-service/src/index.ts | 1 + packages/event-service/src/kiloclaw-contexts.ts | 14 ++++++++++++++ services/kilo-chat/package.json | 1 + .../__tests__/conversation-status-routes.test.ts | 3 ++- services/kilo-chat/src/services/event-push.ts | 5 +++-- 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 packages/event-service/src/kiloclaw-contexts.ts 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..22881389ed --- /dev/null +++ b/packages/event-service/src/kiloclaw-contexts.ts @@ -0,0 +1,14 @@ +/** + * 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/services/kilo-chat/package.json b/services/kilo-chat/package.json index 9eb1492829..031714384a 100644 --- a/services/kilo-chat/package.json +++ b/services/kilo-chat/package.json @@ -27,6 +27,7 @@ "dependencies": { "@kilocode/db": "workspace:*", "@kilocode/encryption": "workspace:*", + "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", "@kilocode/worker-utils": "workspace:*", "drizzle-orm": "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/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)) From 87f0fabbcfd8b2928689d686d32ae43ede39e31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:16:20 +0200 Subject: [PATCH 12/88] feat(kilo-chat): add fetchSandboxLabel helper --- .../kilo-chat/src/services/sandbox-lookup.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 services/kilo-chat/src/services/sandbox-lookup.ts 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'; +} From 822d3274bbe17d33c262bbf20b998c64d889beee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:17:33 +0200 Subject: [PATCH 13/88] chore(kilo-chat): add NOTIFICATIONS service binding --- services/kilo-chat/worker-configuration.d.ts | 3 ++- services/kilo-chat/wrangler.jsonc | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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..b07064c308 100644 --- a/services/kilo-chat/wrangler.jsonc +++ b/services/kilo-chat/wrangler.jsonc @@ -42,6 +42,7 @@ "services": [ { "binding": "KILOCLAW", "service": "kiloclaw" }, { "binding": "EVENT_SERVICE", "service": "event-service" }, + { "binding": "NOTIFICATIONS", "service": "notifications", "entrypoint": "NotificationsService" }, ], "secrets_store_secrets": [ From 372f0a07e2a86f961a68ddfbb38b9c9bc9d1c6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:25:38 +0200 Subject: [PATCH 14/88] feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- pnpm-lock.yaml | 6 + services/kilo-chat/package.json | 1 + .../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/messages.ts | 28 +++ services/kilo-chat/src/util/content.ts | 12 ++ services/kilo-chat/vitest.config.mts | 16 ++ 8 files changed, 251 insertions(+) create mode 100644 services/kilo-chat/src/__tests__/push-notifications.test.ts create mode 100644 services/kilo-chat/src/util/content.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40be24ffe2..fb43aabfd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/kilo-chat/package.json b/services/kilo-chat/package.json index 031714384a..283de5871b 100644 --- a/services/kilo-chat/package.json +++ b/services/kilo-chat/package.json @@ -29,6 +29,7 @@ "@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__/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/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/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: [] }; + } + } + `, + }, ], }, }, From 52fe8a691070f1106119290c23483a42a7a25b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 18:53:51 +0200 Subject: [PATCH 15/88] 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). --- .../event-service/src/kiloclaw-contexts.ts | 3 +- pnpm-lock.yaml | 3 - services/notifications/package.json | 1 - .../src/__tests__/dispatch-push.test.ts | 2 +- .../send-push-for-conversation.test.ts | 10 +- services/notifications/src/__tests__/setup.ts | 2 +- .../notifications/worker-configuration.d.ts | 576 ++++++++++++++++-- 7 files changed, 535 insertions(+), 62 deletions(-) diff --git a/packages/event-service/src/kiloclaw-contexts.ts b/packages/event-service/src/kiloclaw-contexts.ts index 22881389ed..afbb6bc665 100644 --- a/packages/event-service/src/kiloclaw-contexts.ts +++ b/packages/event-service/src/kiloclaw-contexts.ts @@ -7,8 +7,7 @@ * whether the user is actively on a surface. */ -export const kiloclawInstanceContext = (sandboxId: string) => - `/kiloclaw/${sandboxId}` as const; +export const kiloclawInstanceContext = (sandboxId: string) => `/kiloclaw/${sandboxId}` as const; export const kiloclawConversationContext = (sandboxId: string, conversationId: string) => `/kiloclaw/${sandboxId}/${conversationId}` as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb43aabfd6..3ea3a33947 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2032,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 diff --git a/services/notifications/package.json b/services/notifications/package.json index 05fed40c18..a304e241c6 100644 --- a/services/notifications/package.json +++ b/services/notifications/package.json @@ -28,7 +28,6 @@ "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 index 29160f8b08..d00f981684 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -20,7 +20,7 @@ type DbState = { function installDbMock(state: DbState) { const fakeDb = { - select: (cols?: unknown) => ({ + select: () => ({ from: (table: Parameters[0]) => ({ where: async () => { if (getTableName(table) === 'user_push_tokens') { 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 8b993be145..2eb5365773 100644 --- a/services/notifications/src/__tests__/send-push-for-conversation.test.ts +++ b/services/notifications/src/__tests__/send-push-for-conversation.test.ts @@ -6,9 +6,11 @@ import type { SendPushForConversationInput, } from '@kilocode/notifications'; -import * as do_module from '../dos/NotificationChannelDO'; +import type * as do_module from '../dos/NotificationChannelDO'; -const baseInput = (over: Partial = {}): SendPushForConversationInput => ({ +const baseInput = ( + over: Partial = {} +): SendPushForConversationInput => ({ conversationId: 'conv1', sandboxId: 'sb1', senderUserId: 'sender', @@ -50,7 +52,9 @@ describe('NotificationsService.sendPushForConversation', () => { dispatchPush: stubSpy, } as unknown as DurableObjectStub); - await env.SELF.sendPushForConversation(baseInput({ recipientUserIds: ['r1'], senderUserId: null })); + 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]; diff --git a/services/notifications/src/__tests__/setup.ts b/services/notifications/src/__tests__/setup.ts index ad1be4336a..d54904d430 100644 --- a/services/notifications/src/__tests__/setup.ts +++ b/services/notifications/src/__tests__/setup.ts @@ -6,7 +6,7 @@ vi.mock('@kilocode/db/client', () => ({ from: (table: { _: { name: string } }) => ({ where: () => { if (table._.name === 'user_push_tokens') return []; - if (table._.name === 'channel_badge_counts') return [{ total: 0 }]; + if (table._.name === 'badge_counts') return [{ total: 0 }]; return []; }, }), diff --git a/services/notifications/worker-configuration.d.ts b/services/notifications/worker-configuration.d.ts index 6a3ebcce40..0e8a5f8948 100644 --- a/services/notifications/worker-configuration.d.ts +++ b/services/notifications/worker-configuration.d.ts @@ -1,17 +1,17 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 0819ae5a59d68074ad7e23557cbc10b7) -// Runtime types generated with workerd@1.20251217.0 2026-02-01 nodejs_compat +// 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 { mainModule: typeof import("./src/index"); durableNamespaces: "NotificationChannelDO"; } interface Env { - NOTIFICATION_CHANNEL_DO: DurableObjectNamespace; + HYPERDRIVE: Hyperdrive; + RECEIPTS_QUEUE: Queue; EXPO_ACCESS_TOKEN: SecretsStoreSecret; + NOTIFICATION_CHANNEL_DO: DurableObjectNamespace; EVENT_SERVICE: Fetcher /* event-service */; - RECEIPTS_QUEUE: Queue; - HYPERDRIVE: Hyperdrive; } } interface Env extends Cloudflare.Env {} @@ -439,22 +439,22 @@ interface ExecutionContext { readonly exports: Cloudflare.Exports; readonly props: Props; } -type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; -type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; -type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; -type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; -interface ExportedHandler { - fetch?: ExportedHandlerFetchHandler; - tail?: ExportedHandlerTailHandler; - trace?: ExportedHandlerTraceHandler; - tailStream?: ExportedHandlerTailStreamHandler; - scheduled?: ExportedHandlerScheduledHandler; - test?: ExportedHandlerTestHandler; - email?: EmailExportedHandler; - queue?: ExportedHandlerQueueHandler; +type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; +type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise; +type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise; +type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise; +interface ExportedHandler { + fetch?: ExportedHandlerFetchHandler; + tail?: ExportedHandlerTailHandler; + trace?: ExportedHandlerTraceHandler; + tailStream?: ExportedHandlerTailStreamHandler; + scheduled?: ExportedHandlerScheduledHandler; + test?: ExportedHandlerTestHandler; + email?: EmailExportedHandler; + queue?: ExportedHandlerQueueHandler; } interface StructuredSerializeOptions { transfer?: any[]; @@ -502,8 +502,10 @@ interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; + routingMode?: DurableObjectRoutingMode; } interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { } @@ -1391,6 +1393,12 @@ declare abstract class PromiseRejectionEvent extends Event { */ declare class FormData { constructor(); + /** + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) + */ + append(name: string, value: string | Blob): void; /** * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * @@ -1427,6 +1435,12 @@ declare class FormData { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) */ has(name: string): boolean; + /** + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) + */ + set(name: string, value: string | Blob): void; /** * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * @@ -1753,7 +1767,7 @@ interface Request> e * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/signal) */ signal: AbortSignal; - cf: Cf | undefined; + cf?: Cf; /** * The **`integrity`** read-only property of the Request interface contains the subresource integrity value of the request. * @@ -2088,6 +2102,8 @@ interface Transformer { expectedLength?: number; } interface StreamPipeOptions { + preventAbort?: boolean; + preventCancel?: boolean; /** * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. * @@ -2106,8 +2122,6 @@ interface StreamPipeOptions { * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. */ preventClose?: boolean; - preventAbort?: boolean; - preventCancel?: boolean; signal?: AbortSignal; } type ReadableStreamReadResult = { @@ -2382,13 +2396,13 @@ declare abstract class TransformStreamDefaultController { terminate(): void; } interface ReadableWritablePair { + readable: ReadableStream; /** * Provides a convenient, chainable way of piping this readable stream through a transform stream (or any other { writable, readable } pair). It simply pipes the stream into the writable side of the supplied pair, and returns the readable side for further use. * * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. */ writable: WritableStream; - readable: ReadableStream; } /** * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. @@ -3036,7 +3050,7 @@ declare var WebSocket: { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) */ interface WebSocket extends EventTarget { - accept(): void; + accept(options?: WebSocketAcceptOptions): void; /** * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. * @@ -3075,6 +3089,22 @@ interface WebSocket extends EventTarget { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) */ extensions: string | null; + /** + * The **`WebSocket.binaryType`** property controls the type of binary data being received over the WebSocket connection. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) + */ + binaryType: "blob" | "arraybuffer"; +} +interface WebSocketAcceptOptions { + /** + * When set to `true`, receiving a server-initiated WebSocket Close frame will not + * automatically send a reciprocal Close frame, leaving the connection in a half-open + * state. This is useful for proxying scenarios where you need to coordinate closing + * both sides independently. Defaults to `false` when the + * `no_web_socket_half_open_by_default` compatibility flag is enabled. + */ + allowHalfOpen?: boolean; } declare const WebSocketPair: { new (): { @@ -3193,6 +3223,8 @@ interface Container { signal(signo: number): void; getTcpPort(port: number): Fetcher; setInactivityTimeout(durationMs: number | bigint): Promise; + interceptOutboundHttp(addr: string, binding: Fetcher): Promise; + interceptAllOutboundHttp(binding: Fetcher): Promise; } interface ContainerStartupOptions { entrypoint?: string[]; @@ -3318,6 +3350,181 @@ declare abstract class Performance { get timeOrigin(): number; /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ now(): number; + /** + * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) + */ + toJSON(): object; +} +// AI Search V2 API Error Interfaces +interface AiSearchInternalError extends Error { +} +interface AiSearchNotFoundError extends Error { +} +interface AiSearchNameNotSetError extends Error { +} +// AI Search V2 Request Types +type AiSearchSearchRequest = { + messages: Array<{ + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; + }>; + ai_search_options?: { + retrieval?: { + retrieval_type?: 'vector' | 'keyword' | 'hybrid'; + /** Match threshold (0-1, default 0.4) */ + match_threshold?: number; + /** Maximum number of results (1-50, default 10) */ + max_num_results?: number; + filters?: VectorizeVectorMetadataFilter; + /** Context expansion (0-3, default 0) */ + context_expansion?: number; + [key: string]: unknown; + }; + query_rewrite?: { + enabled?: boolean; + model?: string; + rewrite_prompt?: string; + [key: string]: unknown; + }; + reranking?: { + /** Enable reranking (default false) */ + enabled?: boolean; + model?: '@cf/baai/bge-reranker-base' | ''; + /** Match threshold (0-1, default 0.4) */ + match_threshold?: number; + [key: string]: unknown; + }; + [key: string]: unknown; + }; +}; +type AiSearchChatCompletionsRequest = { + messages: Array<{ + role: 'system' | 'developer' | 'user' | 'assistant' | 'tool'; + content: string | null; + }>; + model?: string; + stream?: boolean; + ai_search_options?: { + retrieval?: { + retrieval_type?: 'vector' | 'keyword' | 'hybrid'; + match_threshold?: number; + max_num_results?: number; + filters?: VectorizeVectorMetadataFilter; + context_expansion?: number; + [key: string]: unknown; + }; + query_rewrite?: { + enabled?: boolean; + model?: string; + rewrite_prompt?: string; + [key: string]: unknown; + }; + reranking?: { + enabled?: boolean; + model?: '@cf/baai/bge-reranker-base' | ''; + match_threshold?: number; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +}; +// AI Search V2 Response Types +type AiSearchSearchResponse = { + search_query: string; + chunks: Array<{ + id: string; + type: string; + /** Match score (0-1) */ + score: number; + text: string; + item: { + timestamp?: number; + key: string; + metadata?: Record; + }; + scoring_details?: { + /** Keyword match score (0-1) */ + keyword_score?: number; + /** Vector similarity score (0-1) */ + vector_score?: number; + }; + }>; +}; +type AiSearchListResponse = Array<{ + id: string; + internal_id?: string; + account_id?: string; + account_tag?: string; + /** Whether the instance is enabled (default true) */ + enable?: boolean; + type?: 'r2' | 'web-crawler'; + source?: string; + [key: string]: unknown; +}>; +type AiSearchConfig = { + /** Instance ID (1-32 chars, pattern: ^[a-z0-9_]+(?:-[a-z0-9_]+)*$) */ + id: string; + type: 'r2' | 'web-crawler'; + source: string; + source_params?: object; + /** Token ID (UUID format) */ + token_id?: string; + ai_gateway_id?: string; + /** Enable query rewriting (default false) */ + rewrite_query?: boolean; + /** Enable reranking (default false) */ + reranking?: boolean; + embedding_model?: string; + ai_search_model?: string; +}; +type AiSearchInstance = { + id: string; + enable?: boolean; + type?: 'r2' | 'web-crawler'; + source?: string; + [key: string]: unknown; +}; +// AI Search Instance Service - Instance-level operations +declare abstract class AiSearchInstanceService { + /** + * Search the AI Search instance for relevant chunks. + * @param params Search request with messages and AI search options + * @returns Search response with matching chunks + */ + search(params: AiSearchSearchRequest): Promise; + /** + * Generate chat completions with AI Search context. + * @param params Chat completions request with optional streaming + * @returns Response object (if streaming) or chat completion result + */ + chatCompletions(params: AiSearchChatCompletionsRequest): Promise; + /** + * Delete this AI Search instance. + */ + delete(): Promise; +} +// AI Search Account Service - Account-level operations +declare abstract class AiSearchAccountService { + /** + * List all AI Search instances in the account. + * @returns Array of AI Search instances + */ + list(): Promise; + /** + * Get an AI Search instance by ID. + * @param name Instance ID + * @returns Instance service for performing operations + */ + get(name: string): AiSearchInstanceService; + /** + * Create a new AI Search instance. + * @param config Instance configuration + * @returns Instance service for performing operations + */ + create(config: AiSearchConfig): Promise; } type AiImageClassificationInput = { image: number[]; @@ -5507,7 +5714,7 @@ interface Ai_Cf_Qwen_Qwq_32B_Messages { }; })[]; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -5773,7 +5980,7 @@ interface Ai_Cf_Mistralai_Mistral_Small_3_1_24B_Instruct_Messages { }; })[]; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -5864,7 +6071,7 @@ interface Ai_Cf_Google_Gemma_3_12B_It_Prompt { */ prompt: string; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -6023,7 +6230,7 @@ interface Ai_Cf_Google_Gemma_3_12B_It_Messages { }; })[]; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -6295,7 +6502,7 @@ interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages { })[]; response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -6525,7 +6732,7 @@ interface Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_Messages_Inner { })[]; response_format?: Ai_Cf_Meta_Llama_4_Scout_17B_16E_Instruct_JSON_Mode; /** - * JSON schema that should be fufilled for the response. + * JSON schema that should be fulfilled for the response. */ guided_json?: object; /** @@ -7575,7 +7782,7 @@ interface Ai_Cf_Ai4Bharat_Indictrans2_En_Indic_1B_Input { */ text: string | string[]; /** - * Target langauge to translate to + * Target language to translate to */ target_language: "asm_Beng" | "awa_Deva" | "ben_Beng" | "bho_Deva" | "brx_Deva" | "doi_Deva" | "eng_Latn" | "gom_Deva" | "gon_Deva" | "guj_Gujr" | "hin_Deva" | "hne_Deva" | "kan_Knda" | "kas_Arab" | "kas_Deva" | "kha_Latn" | "lus_Latn" | "mag_Deva" | "mai_Deva" | "mal_Mlym" | "mar_Deva" | "mni_Beng" | "mni_Mtei" | "npi_Deva" | "ory_Orya" | "pan_Guru" | "san_Deva" | "sat_Olck" | "snd_Arab" | "snd_Deva" | "tam_Taml" | "tel_Telu" | "urd_Arab" | "unr_Deva"; } @@ -8506,6 +8713,48 @@ type AiModelListType = Record; declare abstract class Ai { aiGatewayLogId: string | null; gateway(gatewayId: string): AiGateway; + /** + * Access the AI Search API for managing AI-powered search instances. + * + * This is the new API that replaces AutoRAG with better namespace separation: + * - Account-level operations: `list()`, `create()` + * - Instance-level operations: `get(id).search()`, `get(id).chatCompletions()`, `get(id).delete()` + * + * @example + * ```typescript + * // List all AI Search instances + * const instances = await env.AI.aiSearch.list(); + * + * // Search an instance + * const results = await env.AI.aiSearch.get('my-search').search({ + * messages: [{ role: 'user', content: 'What is the policy?' }], + * ai_search_options: { + * retrieval: { max_num_results: 10 } + * } + * }); + * + * // Generate chat completions with AI Search context + * const response = await env.AI.aiSearch.get('my-search').chatCompletions({ + * messages: [{ role: 'user', content: 'What is the policy?' }], + * model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast' + * }); + * ``` + */ + aiSearch(): AiSearchAccountService; + /** + * @deprecated AutoRAG has been replaced by AI Search. + * Use `env.AI.aiSearch` instead for better API design and new features. + * + * Migration guide: + * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()` + * - `env.AI.autorag('id').search({ query: '...' })` → `env.AI.aiSearch.get('id').search({ messages: [{ role: 'user', content: '...' }] })` + * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)` + * + * Note: The old API continues to work for backwards compatibility, but new projects should use AI Search. + * + * @see AiSearchAccountService + * @param autoragId Optional instance ID (omit for account-level operations) + */ autorag(autoragId: string): AutoRAG; run(model: Name, inputs: InputOptions, options?: Options): Promise; getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line } +/** + * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchInternalError instead. + * @see AiSearchInternalError + */ interface AutoRAGInternalError extends Error { } +/** + * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNotFoundError instead. + * @see AiSearchNotFoundError + */ interface AutoRAGNotFoundError extends Error { } +/** + * @deprecated This error type is no longer used in the AI Search API. + */ interface AutoRAGUnauthorizedError extends Error { } +/** + * @deprecated AutoRAG has been replaced by AI Search. Use AiSearchNameNotSetError instead. + * @see AiSearchNameNotSetError + */ interface AutoRAGNameNotSetError extends Error { } type ComparisonFilter = { @@ -8631,6 +8895,11 @@ type CompoundFilter = { type: 'and' | 'or'; filters: ComparisonFilter[]; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use AiSearchSearchRequest with the new API instead. + * @see AiSearchSearchRequest + */ type AutoRagSearchRequest = { query: string; filters?: CompoundFilter | ComparisonFilter; @@ -8645,13 +8914,28 @@ type AutoRagSearchRequest = { }; rewrite_query?: boolean; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use AiSearchChatCompletionsRequest with the new API instead. + * @see AiSearchChatCompletionsRequest + */ type AutoRagAiSearchRequest = AutoRagSearchRequest & { stream?: boolean; system_prompt?: string; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use AiSearchChatCompletionsRequest with stream: true instead. + * @see AiSearchChatCompletionsRequest + */ type AutoRagAiSearchRequestStreaming = Omit & { stream: true; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use AiSearchSearchResponse with the new API instead. + * @see AiSearchSearchResponse + */ type AutoRagSearchResponse = { object: 'vector_store.search_results.page'; search_query: string; @@ -8668,6 +8952,11 @@ type AutoRagSearchResponse = { has_more: boolean; next_page: string | null; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use AiSearchListResponse with the new API instead. + * @see AiSearchListResponse + */ type AutoRagListResponse = { id: string; enable: boolean; @@ -8677,14 +8966,51 @@ type AutoRagListResponse = { paused: boolean; status: string; }[]; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * The new API returns different response formats for chat completions. + */ type AutoRagAiSearchResponse = AutoRagSearchResponse & { response: string; }; +/** + * @deprecated AutoRAG has been replaced by AI Search. + * Use the new AI Search API instead: `env.AI.aiSearch` + * + * Migration guide: + * - `env.AI.autorag().list()` → `env.AI.aiSearch.list()` + * - `env.AI.autorag('id').search(...)` → `env.AI.aiSearch.get('id').search(...)` + * - `env.AI.autorag('id').aiSearch(...)` → `env.AI.aiSearch.get('id').chatCompletions(...)` + * + * @see AiSearchAccountService + * @see AiSearchInstanceService + */ declare abstract class AutoRAG { + /** + * @deprecated Use `env.AI.aiSearch.list()` instead. + * @see AiSearchAccountService.list + */ list(): Promise; + /** + * @deprecated Use `env.AI.aiSearch.get(id).search(...)` instead. + * Note: The new API uses a messages array instead of a query string. + * @see AiSearchInstanceService.search + */ search(params: AutoRagSearchRequest): Promise; + /** + * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. + * @see AiSearchInstanceService.chatCompletions + */ aiSearch(params: AutoRagAiSearchRequestStreaming): Promise; + /** + * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. + * @see AiSearchInstanceService.chatCompletions + */ aiSearch(params: AutoRagAiSearchRequest): Promise; + /** + * @deprecated Use `env.AI.aiSearch.get(id).chatCompletions(...)` instead. + * @see AiSearchInstanceService.chatCompletions + */ aiSearch(params: AutoRagAiSearchRequest): Promise; } interface BasicImageTransformations { @@ -9435,6 +9761,10 @@ interface D1Meta { * The region of the database instance that executed the query. */ served_by_region?: string; + /** + * The three letters airport code of the colo that executed the query. + */ + served_by_colo?: string; /** * True if-and-only-if the database instance that executed the query was the primary. */ @@ -9523,6 +9853,15 @@ declare abstract class D1PreparedStatement { // ignored when `Disposable` is included in the standard lib. interface Disposable { } +/** + * The returned data after sending an email + */ +interface EmailSendResult { + /** + * The Email Message ID + */ + messageId: string; +} /** * An email message that can be sent from a Worker. */ @@ -9564,24 +9903,55 @@ interface ForwardableEmailMessage extends EmailMessage { * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). * @returns A promise that resolves when the email message is forwarded. */ - forward(rcptTo: string, headers?: Headers): Promise; + forward(rcptTo: string, headers?: Headers): Promise; /** * Reply to the sender of this email message with a new EmailMessage object. * @param message The reply message. * @returns A promise that resolves when the email message is replied. */ - reply(message: EmailMessage): Promise; + reply(message: EmailMessage): Promise; +} +/** A file attachment for an email message */ +type EmailAttachment = { + disposition: 'inline'; + contentId: string; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; +} | { + disposition: 'attachment'; + contentId?: undefined; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; +}; +/** An Email Address */ +interface EmailAddress { + name: string; + email: string; } /** * A binding that allows a Worker to send email messages. */ interface SendEmail { - send(message: EmailMessage): Promise; + send(message: EmailMessage): Promise; + send(builder: { + from: string | EmailAddress; + to: string | string[]; + subject: string; + replyTo?: string | EmailAddress; + cc?: string | string[]; + bcc?: string | string[]; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; + }): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; } -declare type EmailExportedHandler = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) => void | Promise; +declare type EmailExportedHandler = (message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) => void | Promise; declare module "cloudflare:email" { let _EmailMessage: { prototype: EmailMessage; @@ -9609,7 +9979,7 @@ interface Hyperdrive { /** * Connect directly to Hyperdrive as if it's your database, returning a TCP socket. * - * Calling this method returns an idential socket to if you call + * Calling this method returns an identical socket to if you call * `connect("host:port")` using the `host` and `port` fields from this object. * Pick whichever approach works better with your preferred DB client library. * @@ -9722,6 +10092,83 @@ type ImageOutputOptions = { background?: string; anim?: boolean; }; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + encoding?: 'base64'; +} +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; +} +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: 'asc' | 'desc'; + creator?: string; +} +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} +interface HostedImagesBinding { + /** + * Get detailed metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + details(imageId: string): Promise; + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + image(imageId: string): Promise | null>; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload(image: ReadableStream | ArrayBuffer, options?: ImageUploadOptions): Promise; + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; +} interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -9735,6 +10182,10 @@ interface ImagesBinding { * @returns A transform handle */ input(stream: ReadableStream, options?: ImageInputOptions): ImageTransformer; + /** + * Access hosted images CRUD operations + */ + readonly hosted: HostedImagesBinding; } interface ImageTransformer { /** @@ -9801,7 +10252,13 @@ interface MediaTransformer { * @param transform - Configuration for how the media should be transformed * @returns A generator for producing the transformed media output */ - transform(transform: MediaTransformationInputOptions): MediaTransformationGenerator; + transform(transform?: MediaTransformationInputOptions): MediaTransformationGenerator; + /** + * Generates the final media output with specified options. + * @param output - Configuration for the output format and parameters + * @returns The final transformation result containing the transformed media + */ + output(output?: MediaTransformationOutputOptions): MediaTransformationResult; } /** * Generator for producing media transformation results. @@ -9813,7 +10270,7 @@ interface MediaTransformationGenerator { * @param output - Configuration for the output format and parameters * @returns The final transformation result containing the transformed media */ - output(output: MediaTransformationOutputOptions): MediaTransformationResult; + output(output?: MediaTransformationOutputOptions): MediaTransformationResult; } /** * Result of a media transformation operation. @@ -9822,19 +10279,19 @@ interface MediaTransformationGenerator { interface MediaTransformationResult { /** * Returns the transformed media as a readable stream of bytes. - * @returns A stream containing the transformed media data + * @returns A promise containing a readable stream with the transformed media */ - media(): ReadableStream; + media(): Promise>; /** * Returns the transformed media as an HTTP response object. - * @returns The transformed media as a Response, ready to store in cache or return to users + * @returns The transformed media as a Promise, ready to store in cache or return to users */ - response(): Response; + response(): Promise; /** * Returns the MIME type of the transformed media. - * @returns The content type string (e.g., 'image/jpeg', 'video/mp4') + * @returns A promise containing the content type string (e.g., 'image/jpeg', 'video/mp4') */ - contentType(): string; + contentType(): Promise; } /** * Configuration options for transforming media input. @@ -9942,7 +10399,7 @@ declare module "cloudflare:pipelines" { protected ctx: ExecutionContext; constructor(ctx: ExecutionContext, env: Env); /** - * run recieves an array of PipelineRecord which can be + * run receives an array of PipelineRecord which can be * transformed and returned to the pipeline * @param records Incoming records from the pipeline to be transformed * @param metadata Information about the specific pipeline calling the transformation entrypoint @@ -10213,9 +10670,12 @@ declare namespace CloudflareWorkersModule { timestamp: Date; type: string; }; + export type WorkflowStepContext = { + attempt: number; + }; export abstract class WorkflowStep { - do>(name: string, callback: () => Promise): Promise; - do>(name: string, config: WorkflowStepConfig, callback: () => Promise): Promise; + do>(name: string, callback: (ctx: WorkflowStepContext) => Promise): Promise; + do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -10223,6 +10683,7 @@ declare namespace CloudflareWorkersModule { timeout?: WorkflowTimeoutDuration | number; }): Promise>; } + export type WorkflowInstanceStatus = 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown'; export abstract class WorkflowEntrypoint | unknown = unknown> implements Rpc.WorkflowEntrypointBranded { [Rpc.__WORKFLOW_ENTRYPOINT_BRAND]: never; protected ctx: ExecutionContext; @@ -10256,12 +10717,14 @@ type MarkdownDocument = { blob: Blob; }; type ConversionResponse = { + id: string; name: string; mimeType: string; format: 'markdown'; tokens: number; data: string; } | { + id: string; name: string; mimeType: string; format: 'error'; @@ -10279,6 +10742,8 @@ type ConversionOptions = { images?: EmbeddedImageConversionOptions & { convertOGImage?: boolean; }; + hostname?: string; + cssSelector?: string; }; docx?: { images?: EmbeddedImageConversionOptions; @@ -10416,6 +10881,15 @@ declare namespace TailStream { readonly level: "debug" | "error" | "info" | "log" | "warn"; readonly message: object; } + interface DroppedEventsDiagnostic { + readonly diagnosticsType: "droppedEvents"; + readonly count: number; + } + interface StreamDiagnostic { + readonly type: 'streamDiagnostic'; + // To add new diagnostic types, define a new interface and add it to this union type. + readonly diagnostic: DroppedEventsDiagnostic; + } // This marks the worker handler return information. // This is separate from Outcome because the worker invocation can live for a long time after // returning. For example - Websockets that return an http upgrade response but then continue @@ -10432,7 +10906,7 @@ declare namespace TailStream { readonly type: "attributes"; readonly info: Attribute[]; } - type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | Return | Attributes; + type EventType = Onset | Outcome | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | StreamDiagnostic | Return | Attributes; // Context in which this trace event lives. interface SpanContext { // Single id for the entire top-level invocation @@ -10446,7 +10920,7 @@ declare namespace TailStream { // For Hibernate and Mark this would be the span under which they were emitted. // spanId is not set ONLY if: // 1. This is an Onset event - // 2. We are not inherting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) + // 2. We are not inheriting any SpanContext. (e.g. this is a cross-account service binding or a new top-level invocation) readonly spanId?: string; } interface TailEvent { From 4e95291512e1f35275be4a38f0cc0fc2ad6ac1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 19:04:43 +0200 Subject: [PATCH 16/88] fix(notifications): named entrypoint export, retry-safe badge, alarm-leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/__tests__/dispatch-push.test.ts | 58 +++++++++++++- .../src/dos/NotificationChannelDO.ts | 76 +++++++++++++------ services/notifications/src/index.ts | 4 +- 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index d00f981684..6a7de49339 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:test'; +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'; @@ -128,4 +128,60 @@ describe('NotificationChannelDO.dispatchPush', () => { 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('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/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index ee73d09944..a0c7e3858e 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -9,17 +9,25 @@ import { sendPushNotifications } from '../lib/expo-push'; type ReceiptCheckMessage = { ticketTokenPairs: TicketTokenPair[] }; +// 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 IDEM_PREFIX = 'idem:'; 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 does NOT write the - // idempotency key, so the next attempt can retry. + // 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 seen = await this.ctx.storage.get(idemKey); - if (seen) return { kind: 'duplicate' }; + 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( @@ -38,20 +46,31 @@ export class NotificationChannelDO extends DurableObject { if (tokens.length === 0) return { kind: 'no_tokens' }; - // 4. Badge math (only if badge is set). + // 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) { - 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}` }, + if (!isRetry) { + // Mark `pending` BEFORE the increment so any later failure path + // is gated on the marker and a retry skips the increment. + await this.ctx.storage.put(idemKey, { + stage: 'pending', + ts: Date.now(), }); + 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) @@ -75,9 +94,8 @@ export class NotificationChannelDO extends DurableObject { try { result = await sendPushNotifications(messages, accessToken); } catch (err) { - // Intentionally do NOT write the idempotency key on failure — let - // upstream retry. The DO's single-threading prevents concurrent - // double-sends within the same conversation. + // 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), @@ -93,19 +111,27 @@ export class NotificationChannelDO extends DurableObject { await this.env.RECEIPTS_QUEUE.send(receiptMsg, { delaySeconds: 900 }); } - // 6. Idempotency write — only after a successful send. - await this.ctx.storage.put(idemKey, Date.now()); - await this.ctx.storage.setAlarm(Date.now() + IDEM_TTL_MS); + // 6. Mark `delivered` so future retries short-circuit as duplicate. + await this.ctx.storage.put(idemKey, { + stage: 'delivered', + ts: Date.now(), + }); + // 7. Schedule cleanup only when no alarm is already pending. + // `setAlarm` replaces any existing alarm; calling it on every push + // would push cleanup forward indefinitely on a busy conversation. + if ((await this.ctx.storage.getAlarm()) === null) { + await this.ctx.storage.setAlarm(Date.now() + IDEM_TTL_MS); + } return { kind: 'delivered', tokenCount: tokens.length }; } override async alarm(): Promise { const now = Date.now(); - const entries = await this.ctx.storage.list({ prefix: IDEM_PREFIX }); + const entries = await this.ctx.storage.list({ prefix: IDEM_PREFIX }); const expired: string[] = []; - for (const [key, ts] of entries) { - if (now - ts > IDEM_TTL_MS) expired.push(key); + for (const [key, rec] of entries) { + if (now - rec.ts > IDEM_TTL_MS) expired.push(key); } if (expired.length > 0) await this.ctx.storage.delete(expired); } diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts index 37dabfd4f9..bb4f673346 100644 --- a/services/notifications/src/index.ts +++ b/services/notifications/src/index.ts @@ -67,7 +67,7 @@ export async function sendPushForConversationCore( return { perRecipient }; } -export default class NotificationsService extends WorkerEntrypoint { +export class NotificationsService extends WorkerEntrypoint { override async fetch(request: Request): Promise { return app.fetch(request, this.env, this.ctx); } @@ -87,3 +87,5 @@ export default class NotificationsService extends WorkerEntrypoint { }); } } + +export default NotificationsService; From 4faf0dd184de8b840c97bfa92f0b510bbfe59b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 19:11:26 +0200 Subject: [PATCH 17/88] 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. --- .../src/__tests__/dispatch-push.test.ts | 41 ++++++++++++++++++ .../src/dos/NotificationChannelDO.ts | 43 ++++++++++++------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index 6a7de49339..1e9dca7fd3 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -164,6 +164,47 @@ describe('NotificationChannelDO.dispatchPush', () => { 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); diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index a0c7e3858e..195bdd82e9 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -55,10 +55,11 @@ export class NotificationChannelDO extends DurableObject { if (!isRetry) { // Mark `pending` BEFORE the increment so any later failure path // is gated on the marker and a retry skips the increment. - await this.ctx.storage.put(idemKey, { - stage: 'pending', - ts: Date.now(), - }); + 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({ @@ -112,16 +113,9 @@ export class NotificationChannelDO extends DurableObject { } // 6. Mark `delivered` so future retries short-circuit as duplicate. - await this.ctx.storage.put(idemKey, { - stage: 'delivered', - ts: Date.now(), - }); - // 7. Schedule cleanup only when no alarm is already pending. - // `setAlarm` replaces any existing alarm; calling it on every push - // would push cleanup forward indefinitely on a busy conversation. - if ((await this.ctx.storage.getAlarm()) === null) { - await this.ctx.storage.setAlarm(Date.now() + IDEM_TTL_MS); - } + const ts = Date.now(); + await this.ctx.storage.put(idemKey, { stage: 'delivered', ts }); + await this.ensureCleanupAlarm(ts); return { kind: 'delivered', tokenCount: tokens.length }; } @@ -130,9 +124,28 @@ export class NotificationChannelDO extends DurableObject { 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); + 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); + } + } + + // 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); + } } } From 8d7b9d7fc7d3e7c725e9987284bb8259e69de132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 19:18:33 +0200 Subject: [PATCH 18/88] refactor(event-service): compose presence contexts from kiloclaw helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/event-service/src/presence.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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; From 893b7f1bc0ef85e1aafcd78143dca5a5bc09cf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 19:45:19 +0200 Subject: [PATCH 19/88] feat(web): add kiloChat.getToken tRPC procedure --- apps/web/src/routers/kilo-chat-router.test.ts | 26 +++++++++++++++++++ apps/web/src/routers/kilo-chat-router.ts | 20 ++++++++++++++ apps/web/src/routers/root-router.ts | 2 ++ 3 files changed, 48 insertions(+) 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/routers/kilo-chat-router.test.ts b/apps/web/src/routers/kilo-chat-router.test.ts new file mode 100644 index 0000000000..52569268ea --- /dev/null +++ b/apps/web/src/routers/kilo-chat-router.test.ts @@ -0,0 +1,26 @@ +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +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 JWT-shaped token and a future expiresAt', async () => { + const caller = await createCallerForUser(testUser.id); + const before = Date.now(); + const result = await caller.kiloChat.getToken(); + + expect(result.token).toMatch(/\..+\..+/); + + const expiresAtMs = Date.parse(result.expiresAt); + expect(Number.isNaN(expiresAtMs)).toBe(false); + expect(expiresAtMs).toBeGreaterThan(before); + }); +}); 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..f2d3c4b65b --- /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.string() })) + .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, From a35c98ce81e22bba52d14c6f5ca8f09d0175ef05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 19:50:58 +0200 Subject: [PATCH 20/88] refactor(web): use kiloclaw-context helpers for event subscriptions --- .../(app)/claw/kilo-chat/components/MessageArea.tsx | 3 ++- .../app/(app)/claw/kilo-chat/hooks/useEventService.ts | 10 +++++++--- .../src/app/(app)/claw/kilo-chat/hooks/useMessages.ts | 3 ++- 3 files changed, 11 insertions(+), 5 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 66beb8b8c7..58cc8d4ed1 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 @@ -15,6 +15,7 @@ import { useExecuteAction, } from '../hooks/useMessages'; import { useConversationContext } from '../hooks/useEventService'; +import { kiloclawConversationContext } from '@kilocode/event-service'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { useConversationDetail, @@ -81,7 +82,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) { // 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/hooks/useEventService.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts index 627dc60a33..ac9ec9b7fd 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts @@ -1,5 +1,9 @@ import { useEffect, useMemo } from 'react'; -import { EventServiceClient } from '@kilocode/event-service'; +import { + EventServiceClient, + kiloclawInstanceContext, + kiloclawConversationContext, +} from '@kilocode/event-service'; import { KiloChatClient } from '@kilocode/kilo-chat'; import { KILO_CHAT_URL, EVENT_SERVICE_URL } from '@/lib/constants'; import { clearKiloChatToken } from '../token'; @@ -46,7 +50,7 @@ export function useEventService(getToken: () => Promise) { export function useInstanceContext(eventService: EventServiceClient, sandboxId: string | null) { useEffect(() => { if (!sandboxId) return; - const context = `/kiloclaw/${sandboxId}`; + const context = kiloclawInstanceContext(sandboxId); eventService.subscribe([context]); return () => eventService.unsubscribe([context]); }, [eventService, sandboxId]); @@ -63,7 +67,7 @@ export function useConversationContext( ) { useEffect(() => { if (!sandboxId || !conversationId) return; - const context = `/kiloclaw/${sandboxId}/${conversationId}`; + const context = kiloclawConversationContext(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; From a43585d86ee4b219b5b96b7a831cfaa6ebb32557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:01:13 +0200 Subject: [PATCH 21/88] 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. --- .../kilo-chat/components/KiloChatLayout.tsx | 11 ++- .../claw/kilo-chat/hooks/useEventService.ts | 42 +---------- .../src/app/(app)/claw/kilo-chat/layout.tsx | 5 -- apps/web/src/app/(app)/layout.tsx | 27 ++++--- .../[id]/claw/kilo-chat/layout.tsx | 5 -- apps/web/src/contexts/EventServiceContext.tsx | 75 +++++++++++++++++++ 6 files changed, 97 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/contexts/EventServiceContext.tsx 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..5810f23b4c 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 { useInstanceContext } from '../hooks/useEventService'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; +import { getKiloChatToken } from '../token'; 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,7 +44,7 @@ export function KiloChatLayout({ }: KiloChatLayoutProps) { const router = useRouter(); - const { eventService, kiloChatClient } = useEventService(getToken); + const { eventService, kiloChatClient } = useEventServiceClient(); useInstanceContext(eventService, sandboxId); const queryClient = useQueryClient(); @@ -189,7 +189,7 @@ export function KiloChatLayout({ const contextValue = useMemo( () => ({ - getToken, + getToken: getKiloChatToken, currentUserId, instanceStatus, leavingConversationId, @@ -202,7 +202,6 @@ export function KiloChatLayout({ kiloChatClient, }), [ - getToken, currentUserId, instanceStatus, leavingConversationId, 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 index ac9ec9b7fd..6925e041ce 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts @@ -1,47 +1,9 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { - EventServiceClient, + type EventServiceClient, kiloclawInstanceContext, kiloclawConversationContext, } 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}`). 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}
-
-
-
-
+ + + + +
+ + + +
{children}
+
+
+
+
+
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 ( (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; +} From e98f3707191cf61dca44695eb4c22d42b8bb0bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:01:18 +0200 Subject: [PATCH 22/88] 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. --- apps/web/src/hooks/usePresenceSubscription.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/web/src/hooks/usePresenceSubscription.ts diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts new file mode 100644 index 0000000000..0087fc6b7f --- /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, active: boolean) { + const { eventService } = useEventServiceClient(); + useEffect(() => { + if (!active) return; + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context, active]); +} From 6bfbf9562b5861ed2f229ecad283bea7345a5ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:07:59 +0200 Subject: [PATCH 23/88] 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. --- .../kilo-chat/components/KiloChatLayout.tsx | 7 ++-- .../claw/kilo-chat/components/MessageArea.tsx | 7 ++-- .../kilo-chat/components/kiloChatContext.ts | 1 - .../claw/kilo-chat/hooks/useEventService.ts | 36 ------------------- apps/web/src/contexts/EventServiceContext.tsx | 5 +-- apps/web/src/hooks/usePresenceSubscription.ts | 2 +- 6 files changed, 10 insertions(+), 48 deletions(-) delete mode 100644 apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts 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 5810f23b4c..f85b101a45 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,9 +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 { useInstanceContext } from '../hooks/useEventService'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useEventServiceClient } from '@/contexts/EventServiceContext'; -import { getKiloChatToken } from '../token'; import { useConversations, useCreateConversation, @@ -45,7 +45,7 @@ export function KiloChatLayout({ const router = useRouter(); const { eventService, kiloChatClient } = useEventServiceClient(); - useInstanceContext(eventService, sandboxId); + usePresenceSubscription(sandboxId ? kiloclawInstanceContext(sandboxId) : '', Boolean(sandboxId)); const queryClient = useQueryClient(); const params = useParams<{ conversationId?: string }>(); @@ -189,7 +189,6 @@ export function KiloChatLayout({ const contextValue = useMemo( () => ({ - getToken: getKiloChatToken, 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 58cc8d4ed1..3aa827ae7e 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,8 +14,8 @@ import { useRemoveReaction, useExecuteAction, } from '../hooks/useMessages'; -import { useConversationContext } from '../hooks/useEventService'; import { kiloclawConversationContext } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { useConversationDetail, @@ -77,7 +77,10 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const [renameText, setRenameText] = useState(''); // Subscribe to this conversation's events via the event-service WebSocket - useConversationContext(eventService, sandboxId, conversationId); + usePresenceSubscription( + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : '', + Boolean(sandboxId && conversationId) + ); // Event Service delivers subscribed contexts to every handler, so each // handler must validate the incoming `ctx` against this string before 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 6925e041ce..0000000000 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect } from 'react'; -import { - type EventServiceClient, - kiloclawInstanceContext, - kiloclawConversationContext, -} from '@kilocode/event-service'; - -/** - * 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 = kiloclawInstanceContext(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 = kiloclawConversationContext(sandboxId, conversationId); - eventService.subscribe([context]); - return () => eventService.unsubscribe([context]); - }, [eventService, sandboxId, conversationId]); -} diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx index 2bc5b78c41..03bc458faa 100644 --- a/apps/web/src/contexts/EventServiceContext.tsx +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -4,10 +4,7 @@ import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'r 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'; +import { getKiloChatToken, clearKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; export type EventServiceContextValue = { eventService: EventServiceClient; diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts index 0087fc6b7f..485a3bf957 100644 --- a/apps/web/src/hooks/usePresenceSubscription.ts +++ b/apps/web/src/hooks/usePresenceSubscription.ts @@ -14,7 +14,7 @@ import { useEventServiceClient } from '@/contexts/EventServiceContext'; export function usePresenceSubscription(context: string, active: boolean) { const { eventService } = useEventServiceClient(); useEffect(() => { - if (!active) return; + if (!active || !context) return; eventService.subscribe([context]); return () => { eventService.unsubscribe([context]); From 832e2b77f1263c8daf8337767af102875b2080af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:11:19 +0200 Subject: [PATCH 24/88] feat(web): subscribe to /presence/web while tab is visible --- .../components/PlatformPresenceMount.tsx | 8 ++++++++ apps/web/src/app/(app)/layout.tsx | 2 ++ apps/web/src/hooks/usePlatformPresence.ts | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 apps/web/src/app/(app)/components/PlatformPresenceMount.tsx create mode 100644 apps/web/src/hooks/usePlatformPresence.ts 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 abddb55aa0..afb80e8546 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -6,6 +6,7 @@ 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 }) { @@ -13,6 +14,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { + diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts new file mode 100644 index 0000000000..072158efc1 --- /dev/null +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { presenceContextForPlatform } from '@kilocode/event-service'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function usePlatformPresence() { + 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); + }, []); + + usePresenceSubscription(presenceContextForPlatform('web'), visible); +} From 99b52d592fb438f9fbdfe5ed2367f43dccf66fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:18:28 +0200 Subject: [PATCH 25/88] feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views --- .../PersonalInstancePresenceMount.tsx | 10 +++++++++ apps/web/src/app/(app)/claw/layout.tsx | 2 ++ .../components/OrgInstancePresenceMount.tsx | 13 +++++++++++ .../(app)/organizations/[id]/claw/layout.tsx | 2 ++ apps/web/src/hooks/useInstancePresence.ts | 22 +++++++++++++++++++ apps/web/src/hooks/usePlatformPresence.ts | 4 +--- 6 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx create mode 100644 apps/web/src/hooks/useInstancePresence.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/layout.tsx b/apps/web/src/app/(app)/claw/layout.tsx index 97b5299011..6aa97f7666 100644 --- a/apps/web/src/app/(app)/claw/layout.tsx +++ b/apps/web/src/app/(app)/claw/layout.tsx @@ -1,12 +1,14 @@ 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(); return ( <> + {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/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx index a53196be12..cabace358d 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/layout.tsx @@ -1,10 +1,12 @@ 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 ( <> + {children} diff --git a/apps/web/src/hooks/useInstancePresence.ts b/apps/web/src/hooks/useInstancePresence.ts new file mode 100644 index 0000000000..58c7c3731a --- /dev/null +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -0,0 +1,22 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { presenceContextForInstance } from '@kilocode/event-service'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function useInstancePresence(sandboxId: string | undefined, enabled = true) { + 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); + }, []); + + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : '', + Boolean(sandboxId) && enabled && visible + ); +} diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts index 072158efc1..15b37ce4a1 100644 --- a/apps/web/src/hooks/usePlatformPresence.ts +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -5,9 +5,7 @@ import { presenceContextForPlatform } from '@kilocode/event-service'; import { usePresenceSubscription } from './usePresenceSubscription'; export function usePlatformPresence() { - const [visible, setVisible] = useState( - typeof document === 'undefined' ? true : !document.hidden, - ); + const [visible, setVisible] = useState(typeof document === 'undefined' ? true : !document.hidden); useEffect(() => { const onChange = () => setVisible(!document.hidden); From bdb99c633865a71bbd87c0e048513f73aa96dd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:22:46 +0200 Subject: [PATCH 26/88] refactor(web): extract useDocumentVisible primitive --- apps/web/src/hooks/useDocumentVisible.ts | 21 +++++++++++++++++++++ apps/web/src/hooks/useInstancePresence.ts | 14 +++----------- apps/web/src/hooks/usePlatformPresence.ts | 12 +++--------- 3 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/hooks/useDocumentVisible.ts diff --git a/apps/web/src/hooks/useDocumentVisible.ts b/apps/web/src/hooks/useDocumentVisible.ts new file mode 100644 index 0000000000..6a34452232 --- /dev/null +++ b/apps/web/src/hooks/useDocumentVisible.ts @@ -0,0 +1,21 @@ +'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 index 58c7c3731a..b8a76e72d3 100644 --- a/apps/web/src/hooks/useInstancePresence.ts +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -1,20 +1,12 @@ 'use client'; -import { useEffect, useState } from 'react'; import { presenceContextForInstance } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; import { usePresenceSubscription } from './usePresenceSubscription'; export function useInstancePresence(sandboxId: string | undefined, enabled = true) { - 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); - }, []); - + const visible = useDocumentVisible(); usePresenceSubscription( sandboxId ? presenceContextForInstance(sandboxId) : '', Boolean(sandboxId) && enabled && visible diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts index 15b37ce4a1..86cb6fa2e8 100644 --- a/apps/web/src/hooks/usePlatformPresence.ts +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -1,17 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; import { presenceContextForPlatform } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; import { usePresenceSubscription } from './usePresenceSubscription'; export function usePlatformPresence() { - 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); - }, []); - + const visible = useDocumentVisible(); usePresenceSubscription(presenceContextForPlatform('web'), visible); } From 405b185fad91dd6b29459a6d345ec74d9c4bdf8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:25:18 +0200 Subject: [PATCH 27/88] feat(web): subscribe to conversation presence while tab visible --- .../claw/kilo-chat/components/MessageArea.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 3aa827ae7e..da809c9f63 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,8 +14,12 @@ import { useRemoveReaction, useExecuteAction, } from '../hooks/useMessages'; -import { kiloclawConversationContext } from '@kilocode/event-service'; +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, @@ -76,12 +80,20 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const [isRenamingTitle, setIsRenamingTitle] = useState(false); const [renameText, setRenameText] = useState(''); + const visible = useDocumentVisible(); + // Subscribe to this conversation's events via the event-service WebSocket usePresenceSubscription( sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : '', Boolean(sandboxId && conversationId) ); + // Subscribe to presence for this conversation while the tab is visible. + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : '', + 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. From 4429bdf060a4080e2c0cc24289bba4c1aa7d5b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:32:42 +0200 Subject: [PATCH 28/88] style(web): reflow useDocumentVisible useState init to one line --- apps/web/src/hooks/useDocumentVisible.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useDocumentVisible.ts b/apps/web/src/hooks/useDocumentVisible.ts index 6a34452232..df55c211cc 100644 --- a/apps/web/src/hooks/useDocumentVisible.ts +++ b/apps/web/src/hooks/useDocumentVisible.ts @@ -7,9 +7,7 @@ import { useEffect, useState } from 'react'; * SSR-safe: returns `true` when `document` is undefined. */ export function useDocumentVisible(): boolean { - const [visible, setVisible] = useState( - typeof document === 'undefined' ? true : !document.hidden, - ); + const [visible, setVisible] = useState(typeof document === 'undefined' ? true : !document.hidden); useEffect(() => { const onChange = () => setVisible(!document.hidden); From eca983e2e93849009ffbeb788bc76c6f1a51498a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:39:52 +0200 Subject: [PATCH 29/88] 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 --- .../kilo-chat/components/KiloChatLayout.tsx | 5 ++++- .../claw/kilo-chat/components/MessageArea.tsx | 11 +++++++---- apps/web/src/hooks/useInstancePresence.ts | 2 +- apps/web/src/hooks/usePresenceSubscription.ts | 4 ++-- apps/web/src/routers/kilo-chat-router.test.ts | 19 ++++++++++++++++--- apps/web/src/routers/kilo-chat-router.ts | 2 +- 6 files changed, 31 insertions(+), 12 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 f85b101a45..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 @@ -45,7 +45,10 @@ export function KiloChatLayout({ const router = useRouter(); const { eventService, kiloChatClient } = useEventServiceClient(); - usePresenceSubscription(sandboxId ? kiloclawInstanceContext(sandboxId) : '', Boolean(sandboxId)); + usePresenceSubscription( + sandboxId ? kiloclawInstanceContext(sandboxId) : null, + Boolean(sandboxId) + ); const queryClient = useQueryClient(); const params = useParams<{ conversationId?: string }>(); 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 da809c9f63..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 @@ -82,15 +82,18 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const visible = useDocumentVisible(); - // Subscribe to this conversation's events via the event-service WebSocket + // 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) : '', + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, Boolean(sandboxId && conversationId) ); - // Subscribe to presence for this conversation while the tab is visible. + // 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) : '', + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, Boolean(sandboxId && conversationId) && visible ); diff --git a/apps/web/src/hooks/useInstancePresence.ts b/apps/web/src/hooks/useInstancePresence.ts index b8a76e72d3..085e28f2e2 100644 --- a/apps/web/src/hooks/useInstancePresence.ts +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -8,7 +8,7 @@ import { usePresenceSubscription } from './usePresenceSubscription'; export function useInstancePresence(sandboxId: string | undefined, enabled = true) { const visible = useDocumentVisible(); usePresenceSubscription( - sandboxId ? presenceContextForInstance(sandboxId) : '', + sandboxId ? presenceContextForInstance(sandboxId) : null, Boolean(sandboxId) && enabled && visible ); } diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts index 485a3bf957..56bb55a381 100644 --- a/apps/web/src/hooks/usePresenceSubscription.ts +++ b/apps/web/src/hooks/usePresenceSubscription.ts @@ -11,10 +11,10 @@ import { useEventServiceClient } from '@/contexts/EventServiceContext'; * Reads the global `EventServiceClient` from `EventServiceProvider`, mounted * in `(app)/layout.tsx` for every authenticated route. */ -export function usePresenceSubscription(context: string, active: boolean) { +export function usePresenceSubscription(context: string | null, active: boolean) { const { eventService } = useEventServiceClient(); useEffect(() => { - if (!active || !context) return; + if (!active || context === null) return; eventService.subscribe([context]); return () => { eventService.unsubscribe([context]); diff --git a/apps/web/src/routers/kilo-chat-router.test.ts b/apps/web/src/routers/kilo-chat-router.test.ts index 52569268ea..9d64775140 100644 --- a/apps/web/src/routers/kilo-chat-router.test.ts +++ b/apps/web/src/routers/kilo-chat-router.test.ts @@ -1,5 +1,8 @@ +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; @@ -12,15 +15,25 @@ describe('kiloChat router - getToken', () => { }); }); - it('returns a JWT-shaped token and a future expiresAt', async () => { + 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(); - expect(result.token).toMatch(/\..+\..+/); + 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); - expect(expiresAtMs).toBeGreaterThan(before); + // 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 index f2d3c4b65b..4b9b4e4cd5 100644 --- a/apps/web/src/routers/kilo-chat-router.ts +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -7,7 +7,7 @@ const KILO_CHAT_TOKEN_TTL_S = 60 * 60; export const kiloChatRouter = createTRPCRouter({ getToken: baseProcedure - .output(z.object({ token: z.string(), expiresAt: z.string() })) + .output(z.object({ token: z.string(), expiresAt: z.iso.datetime() })) .query(({ ctx }) => { const token = generateApiToken( ctx.user, From 7edca1a13ee5a2950cdf25a30ad8200844483923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 20:48:31 +0200 Subject: [PATCH 30/88] 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 9466d50b73..3c68d43db5 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; @@ -215,20 +219,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 }); } } @@ -311,7 +326,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 67e0fe368c74bfc7d04199d6e3af196c1d51ab25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:00:37 +0200 Subject: [PATCH 31/88] chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SERVICE_URL --- apps/mobile/src/lib/config.ts | 3 +++ apps/mobile/src/lib/env-keys.js | 2 ++ 2 files changed, 5 insertions(+) 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', }; From 7b2d7faa5f9c27776eea142e7b4a4fa40c9521be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:01:11 +0200 Subject: [PATCH 32/88] chore(mobile): add kilo-chat workspace deps --- apps/mobile/package.json | 3 +++ pnpm-lock.yaml | 47 ++++++++++------------------------------ 2 files changed, 15 insertions(+), 35 deletions(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index 3ea3a33947..85dd420104 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 @@ -1561,7 +1570,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 @@ -16750,7 +16759,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' @@ -22258,7 +22267,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: @@ -25689,25 +25698,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 @@ -26359,19 +26349,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 0735765109d20cef76d0c46729b627bdedb37f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:01:32 +0200 Subject: [PATCH 33/88] feat(mobile): add kilo-chat token getter with caching --- .../kilo-chat/hooks/use-kilo-chat-token.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts 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..b484c5e150 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-token.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef } from 'react'; + +import { trpcClient } from '@/lib/trpc'; + +type TokenCache = { + token: string; + expiresAtMs: number; +}; + +/** + * 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 dedup ref. + */ +export function useKiloChatTokenGetter(): () => Promise { + const cacheRef = useRef(null); + const inFlightRef = useRef | null>(null); + + return useCallback(async () => { + const cached = cacheRef.current; + if (cached && cached.expiresAtMs - Date.now() > 60_000) { + return cached.token; + } + + if (inFlightRef.current) { + return inFlightRef.current; + } + + const promise = trpcClient.kiloChat.getToken.query().then(({ token, expiresAt }) => { + const expiresAtMs = new Date(expiresAt).getTime(); + cacheRef.current = { token, expiresAtMs }; + inFlightRef.current = null; + return token; + }); + + inFlightRef.current = promise; + return promise; + }, []); +} From 4294250d957387f8a345325b22c132fd7fb88cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:01:53 +0200 Subject: [PATCH 34/88] feat(mobile): add useCurrentUserId from JWT sub --- .../kilo-chat/hooks/use-current-user-id.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts 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..d499643486 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-current-user-id.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +import { useKiloChatTokenGetter } from './use-kilo-chat-token'; + +/** + * Decodes the `sub` 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. + */ +export function useCurrentUserId(): string | null { + const getToken = useKiloChatTokenGetter(); + const [userId, setUserId] = useState(null); + + useEffect(() => { + let cancelled = false; + + getToken() + .then(token => { + if (cancelled) return; + const parts = token.split('.'); + if (parts.length < 2 || !parts[1]) return; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + const parsed = JSON.parse(decoded) as Record; + const sub = typeof parsed.sub === 'string' ? parsed.sub : null; + setUserId(sub); + }) + .catch(() => { + // Leave userId as null on failure + }); + + return () => { + cancelled = true; + }; + }, [getToken]); + + return userId; +} From 4081c29db9f0dbf6dcc9e5cc38cce24fe076269e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:02:21 +0200 Subject: [PATCH 35/88] feat(mobile): add KiloChatProvider --- .../kilo-chat/kilo-chat-provider.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx 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}; +} From 57448bd77b65345fc2563e70a8d527585934a510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:02:33 +0200 Subject: [PATCH 36/88] feat(mobile): add useKiloChatClient and useEventServiceClient hooks --- .../kilo-chat/hooks/use-kilo-chat-client.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts 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..6d57e243e7 --- /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; +} From 257f381c7ddabd9427dfaef38c8d1cff418313ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:05:55 +0200 Subject: [PATCH 37/88] fix(mobile): fix lint errors in kilo-chat token getter --- .../kilo-chat/hooks/use-kilo-chat-token.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 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 b484c5e150..4e05d42755 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 @@ -22,18 +22,24 @@ export function useKiloChatTokenGetter(): () => Promise { return cached.token; } - if (inFlightRef.current) { - return inFlightRef.current; + const existing = inFlightRef.current; + if (existing) { + return existing; } - const promise = trpcClient.kiloChat.getToken.query().then(({ token, expiresAt }) => { - const expiresAtMs = new Date(expiresAt).getTime(); - cacheRef.current = { token, expiresAtMs }; - inFlightRef.current = null; - return token; + // Create a shared promise and set inFlightRef before awaiting so concurrent + // callers share this fetch rather than starting duplicate requests. + let resolveShared: (token: string) => void = () => undefined; + const sharedPromise = new Promise(resolve => { + resolveShared = resolve; }); + inFlightRef.current = sharedPromise; - inFlightRef.current = promise; - return promise; + const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); + const expiresAtMs = new Date(expiresAt).getTime(); + cacheRef.current = { token, expiresAtMs }; + inFlightRef.current = null; + resolveShared(token); + return token; }, []); } From 5844aaf77c6002e8f3f70fa20d64b8ac027270ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:05:58 +0200 Subject: [PATCH 38/88] fix(mobile): fix lint errors in useCurrentUserId hook --- .../kilo-chat/hooks/use-current-user-id.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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 d499643486..edcf453549 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 @@ -13,20 +13,27 @@ export function useCurrentUserId(): string | null { useEffect(() => { let cancelled = false; - getToken() - .then(token => { - if (cancelled) return; + async function fetchUserId() { + try { + const token = await getToken(); + if (cancelled) { + return; + } const parts = token.split('.'); - if (parts.length < 2 || !parts[1]) return; + if (parts.length < 2 || !parts[1]) { + return; + } const payload = parts[1]; - const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/')); const parsed = JSON.parse(decoded) as Record; const sub = typeof parsed.sub === 'string' ? parsed.sub : null; setUserId(sub); - }) - .catch(() => { + } catch { // Leave userId as null on failure - }); + } + } + + void fetchUserId(); return () => { cancelled = true; From 825d1ac2e0681f7401ac2a1fed586a7ccb1a9038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:06:01 +0200 Subject: [PATCH 39/88] fix(mobile): fix lint errors in useKiloChatClient hook --- .../src/components/kilo-chat/hooks/use-kilo-chat-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6d57e243e7..32e7402aa3 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,5 +1,5 @@ -import type { EventServiceClient } from '@kilocode/event-service'; -import type { KiloChatClient } from '@kilocode/kilo-chat'; +import { type EventServiceClient } from '@kilocode/event-service'; +import { type KiloChatClient } from '@kilocode/kilo-chat'; import { useKiloChatContext } from '../kilo-chat-provider'; From fe060dd62337d251b9e1222ebd0a4372ce3a742e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:06:06 +0200 Subject: [PATCH 40/88] feat(mobile): mount KiloChatProvider in (app) layout --- apps/mobile/src/app/(app)/_layout.tsx | 129 +++++++++++++------------- 1 file changed, 66 insertions(+), 63 deletions(-) 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 ( - - - - + - - - - - - - - + > + + + + + + + + + + + + ); } From 029c69ac301abaac643c1a0b514e0505bd781062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:07:33 +0200 Subject: [PATCH 41/88] fix(kilo-chat): assert non-null in base64urlEncode loop --- packages/kilo-chat/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-chat/src/utils.ts b/packages/kilo-chat/src/utils.ts index 741140875d..f3b280f53f 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 (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } From f1eb38cd9bb05fb32da888a19322d4b11a7d9e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 29 Apr 2026 21:10:40 +0200 Subject: [PATCH 42/88] 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. --- .../kilo-chat/hooks/use-kilo-chat-token.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 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 4e05d42755..1ec529d8e4 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 @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { trpcClient } from '@/lib/trpc'; @@ -7,39 +7,48 @@ type TokenCache = { expiresAtMs: number; }; +// Module-level singletons so all useKiloChatTokenGetter() instances share the +// same cache and in-flight dedup. Auth token is a per-app singleton (one +// logged-in user), so process-wide state is correct. +let cache: TokenCache | null = null; +let inFlight: 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 dedup ref. + * fetch via a module-level dedup ref. */ export function useKiloChatTokenGetter(): () => Promise { - const cacheRef = useRef(null); - const inFlightRef = useRef | null>(null); - return useCallback(async () => { - const cached = cacheRef.current; - if (cached && cached.expiresAtMs - Date.now() > 60_000) { - return cached.token; + if (cache && cache.expiresAtMs - Date.now() > 60_000) { + return cache.token; } - const existing = inFlightRef.current; - if (existing) { - return existing; + if (inFlight) { + return inFlight; } - // Create a shared promise and set inFlightRef before awaiting so concurrent + // Create a shared promise and set inFlight before awaiting so concurrent // callers share this fetch rather than starting duplicate requests. let resolveShared: (token: string) => void = () => undefined; - const sharedPromise = new Promise(resolve => { + let rejectShared: (err: unknown) => void = () => undefined; + const sharedPromise = new Promise((resolve, reject) => { resolveShared = resolve; + rejectShared = reject; }); - inFlightRef.current = sharedPromise; + inFlight = sharedPromise; - const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); - const expiresAtMs = new Date(expiresAt).getTime(); - cacheRef.current = { token, expiresAtMs }; - inFlightRef.current = null; - resolveShared(token); - return token; + try { + const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); + const expiresAtMs = new Date(expiresAt).getTime(); + cache = { token, expiresAtMs }; + resolveShared(token); + return token; + } catch (error) { + rejectShared(error); + throw error; + } finally { + inFlight = null; + } }, []); } From ecf29a102a01b390cea10f998afe3af710aa7629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 11:47:24 +0200 Subject: [PATCH 43/88] fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../kilo-chat/hooks/use-current-user-id.ts | 10 ++-- .../kilo-chat/hooks/use-kilo-chat-token.ts | 55 ++++++++++--------- 2 files changed, 34 insertions(+), 31 deletions(-) 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 edcf453549..6c1e850f4c 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 @@ -3,8 +3,10 @@ import { useEffect, useState } from 'react'; import { useKiloChatTokenGetter } from './use-kilo-chat-token'; /** - * Decodes the `sub` 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. + * 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(); @@ -26,8 +28,8 @@ export function useCurrentUserId(): string | null { const payload = parts[1]; const decoded = atob(payload.replaceAll('-', '+').replaceAll('_', '/')); const parsed = JSON.parse(decoded) as Record; - const sub = typeof parsed.sub === 'string' ? parsed.sub : null; - setUserId(sub); + const kiloUserId = typeof parsed.kiloUserId === 'string' ? parsed.kiloUserId : null; + setUserId(kiloUserId); } catch { // Leave userId as null on failure } 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 1ec529d8e4..41e710cfb7 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 @@ -1,17 +1,19 @@ import { useCallback } from 'react'; +import { useAuth } from '@/lib/auth/auth-context'; import { trpcClient } from '@/lib/trpc'; type TokenCache = { + authToken: string; token: string; expiresAtMs: number; }; -// Module-level singletons so all useKiloChatTokenGetter() instances share the -// same cache and in-flight dedup. Auth token is a per-app singleton (one -// logged-in user), so process-wide state is correct. +// 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: Promise | null = null; +let inFlight: { authToken: string; promise: Promise } | null = null; /** * Returns a stable getter function that fetches a kilo-chat JWT, caching it @@ -19,36 +21,35 @@ let inFlight: Promise | null = null; * fetch via a module-level dedup ref. */ export function useKiloChatTokenGetter(): () => Promise { + const { token: authToken } = useAuth(); return useCallback(async () => { - if (cache && cache.expiresAtMs - Date.now() > 60_000) { - return cache.token; + if (!authToken) { + throw new Error('Cannot fetch kilo-chat token: not authenticated'); } - if (inFlight) { - return inFlight; + if (cache && cache.authToken === authToken && cache.expiresAtMs - Date.now() > 60_000) { + return cache.token; } - // Create a shared promise and set inFlight before awaiting so concurrent - // callers share this fetch rather than starting duplicate requests. - let resolveShared: (token: string) => void = () => undefined; - let rejectShared: (err: unknown) => void = () => undefined; - const sharedPromise = new Promise((resolve, reject) => { - resolveShared = resolve; - rejectShared = reject; - }); - inFlight = sharedPromise; + if (inFlight && inFlight.authToken === authToken) { + return inFlight.promise; + } + const slot = { authToken, promise: fetchAndCacheToken(authToken) }; + inFlight = slot; try { - const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); - const expiresAtMs = new Date(expiresAt).getTime(); - cache = { token, expiresAtMs }; - resolveShared(token); - return token; - } catch (error) { - rejectShared(error); - throw error; + return await slot.promise; } finally { - inFlight = null; + // Only clear the slot if a concurrent caller hasn't replaced it. + if (inFlight === slot) { + inFlight = null; + } } - }, []); + }, [authToken]); +} + +async function fetchAndCacheToken(authToken: string): Promise { + const { token, expiresAt } = await trpcClient.kiloChat.getToken.query(); + cache = { authToken, token, expiresAtMs: new Date(expiresAt).getTime() }; + return token; } From d73befb4a10d1b2eb8f3bd45d858e43a2ebf28f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 11:53:26 +0200 Subject: [PATCH 44/88] 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. --- .../kilo-chat/hooks/use-kilo-chat-token.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 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 41e710cfb7..bec3cbaef0 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 @@ -1,6 +1,7 @@ +import * as SecureStore from 'expo-secure-store'; import { useCallback } from 'react'; -import { useAuth } from '@/lib/auth/auth-context'; +import { AUTH_TOKEN_KEY } from '@/lib/storage-keys'; import { trpcClient } from '@/lib/trpc'; type TokenCache = { @@ -19,10 +20,15 @@ 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 { - const { token: authToken } = useAuth(); return useCallback(async () => { + const authToken = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); if (!authToken) { throw new Error('Cannot fetch kilo-chat token: not authenticated'); } @@ -45,7 +51,7 @@ export function useKiloChatTokenGetter(): () => Promise { inFlight = null; } } - }, [authToken]); + }, []); } async function fetchAndCacheToken(authToken: string): Promise { From b89c4d9659a470ad1f08ca1e89435306bd860409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:02:31 +0200 Subject: [PATCH 45/88] feat(mobile): add usePresenceSubscription primitive --- .../kilo-chat/hooks/use-presence-subscription.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts 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..de2e53b5a1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts @@ -0,0 +1,14 @@ +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; + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context, active]); +} From 87a74ed56140a7dbb5a181fb84fc56687eac40ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:03:02 +0200 Subject: [PATCH 46/88] feat(mobile): subscribe to /presence/app while app is active --- apps/mobile/src/app/(app)/_layout.tsx | 136 ++++++++++-------- .../kilo-chat/hooks/use-app-presence.ts | 19 +++ 2 files changed, 92 insertions(+), 63 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts 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-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts new file mode 100644 index 0000000000..5d8c0745a5 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts @@ -0,0 +1,19 @@ +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); +} From 7e20f1c4de79ad1ca797b2f0f7871fa8407a56d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:03:12 +0200 Subject: [PATCH 47/88] feat(mobile): add useInstancePresence hook --- .../kilo-chat/hooks/use-instance-presence.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts 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..dda9686233 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +import { presenceContextForInstance } from '@kilocode/event-service'; + +import { usePresenceSubscription } from './use-presence-subscription'; + +export function useInstancePresence(sandboxId: string | undefined) { + 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); + }, []) + ); + + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : null, + Boolean(sandboxId) && appActive && focused + ); +} From 6d4e6c2c010418573d231eebf254e4b59729fab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:03:19 +0200 Subject: [PATCH 48/88] feat(mobile): add useConversationPresence hook --- .../hooks/use-conversation-presence.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts 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..080aebfd85 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +import { presenceContextForConversation } from '@kilocode/event-service'; + +import { usePresenceSubscription } from './use-presence-subscription'; + +export function useConversationPresence( + sandboxId: string | undefined, + conversationId: string | undefined +) { + 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); + }, []) + ); + + usePresenceSubscription( + sandboxId && conversationId + ? presenceContextForConversation(sandboxId, conversationId) + : null, + Boolean(sandboxId && conversationId) && appActive && focused + ); +} From 4898f4339d189527d711f21ac3b540ed9f83551b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:04:06 +0200 Subject: [PATCH 49/88] fix(mobile): fix lint errors in presence hooks --- .../components/kilo-chat/hooks/use-app-presence.ts | 4 +++- .../kilo-chat/hooks/use-conversation-presence.ts | 12 +++++++----- .../kilo-chat/hooks/use-instance-presence.ts | 8 ++++++-- .../kilo-chat/hooks/use-presence-subscription.ts | 4 +++- 4 files changed, 19 insertions(+), 9 deletions(-) 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 5d8c0745a5..79389de2a0 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 @@ -12,7 +12,9 @@ export function useAppPresence() { const sub = AppState.addEventListener('change', state => { setActive(state === 'active'); }); - return () => sub.remove(); + 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 index 080aebfd85..437534318f 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 @@ -17,20 +17,22 @@ export function useConversationPresence( const sub = AppState.addEventListener('change', state => { setAppActive(state === 'active'); }); - return () => sub.remove(); + return () => { + sub.remove(); + }; }, []); useFocusEffect( useCallback(() => { setFocused(true); - return () => setFocused(false); + return () => { + setFocused(false); + }; }, []) ); usePresenceSubscription( - sandboxId && conversationId - ? presenceContextForConversation(sandboxId, conversationId) - : null, + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, Boolean(sandboxId && conversationId) && appActive && focused ); } 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 dda9686233..e81be747fe 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 @@ -14,13 +14,17 @@ export function useInstancePresence(sandboxId: string | undefined) { const sub = AppState.addEventListener('change', state => { setAppActive(state === 'active'); }); - return () => sub.remove(); + return () => { + sub.remove(); + }; }, []); useFocusEffect( useCallback(() => { setFocused(true); - return () => setFocused(false); + return () => { + setFocused(false); + }; }, []) ); 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 index de2e53b5a1..b3c860bb89 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts @@ -5,7 +5,9 @@ import { useEventServiceClient } from './use-kilo-chat-client'; export function usePresenceSubscription(context: string | null, active: boolean) { const eventService = useEventServiceClient(); useEffect(() => { - if (!active || !context) return; + if (!active || !context) { + return undefined; + } eventService.subscribe([context]); return () => { eventService.unsubscribe([context]); From 1a6b2413480b3273e6ffb227ded5844dae081d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:06:31 +0200 Subject: [PATCH 50/88] feat(mobile): add useEventSubscription primitive --- .../kilo-chat/hooks/use-event-subscription.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts 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..1c9bd401fe --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; + +import { useEventServiceClient } from './use-kilo-chat-client'; + +export function useEventSubscription( + context: string | null, + events: readonly string[], + onEvent: (event: { event: string; payload: unknown }) => void +) { + const eventService = useEventServiceClient(); + useEffect(() => { + if (!context) return undefined; + eventService.subscribe([context]); + const offs = events.map(eventName => + eventService.on(eventName, (ctx, payload) => { + if (ctx === context) onEvent({ event: eventName, payload }); + }) + ); + return () => { + for (const off of offs) off(); + eventService.unsubscribe([context]); + }; + // events is meant to be a stable array literal at the call site; + // join to use as a dependency without forcing memoization on callers. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventService, context, events.join('|'), onEvent]); +} From f0563e876e944fc4fb9c8c0cf62a7f8cfacd5a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:06:45 +0200 Subject: [PATCH 51/88] feat(mobile): add useInstanceEventSubscription --- .../hooks/use-instance-event-subscription.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts 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..04c3c67ac2 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { kiloclawInstanceContext } from '@kilocode/event-service'; + +import { useEventSubscription } from './use-event-subscription'; + +const INSTANCE_EVENTS = [ + 'conversation.created', + 'conversation.left', + 'message.created', + 'message.updated', + 'message.deleted', + 'bot.status', +] as const; + +export function useInstanceEventSubscription(sandboxId: string | undefined) { + const qc = useQueryClient(); + const onEvent = useCallback( + ({ event }: { event: string; payload: unknown }) => { + switch (event) { + case 'conversation.created': + case 'conversation.left': + case 'message.created': + case 'message.updated': + case 'message.deleted': + // message.* invalidates the conversations list so last-message + // preview and unread counts stay current while the user is on + // the list (instance-level presence, not viewing a specific conv). + void qc.invalidateQueries({ queryKey: ['conversations', sandboxId] }); + break; + case 'bot.status': + void qc.invalidateQueries({ queryKey: ['bot-status', sandboxId] }); + break; + default: + break; + } + }, + [qc, sandboxId] + ); + useEventSubscription( + sandboxId ? kiloclawInstanceContext(sandboxId) : null, + INSTANCE_EVENTS, + onEvent + ); +} From b939c3f2013e488bac703e841bd90408dbfcd05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:07:21 +0200 Subject: [PATCH 52/88] fix(mobile): apply curly/switch-case-braces lint rules to event hooks --- .../kilo-chat/hooks/use-event-subscription.ts | 12 +++++++++--- .../hooks/use-instance-event-subscription.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) 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 index 1c9bd401fe..e4ae65fa31 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts @@ -9,15 +9,21 @@ export function useEventSubscription( ) { const eventService = useEventServiceClient(); useEffect(() => { - if (!context) return undefined; + if (!context) { + return undefined; + } eventService.subscribe([context]); const offs = events.map(eventName => eventService.on(eventName, (ctx, payload) => { - if (ctx === context) onEvent({ event: eventName, payload }); + if (ctx === context) { + onEvent({ event: eventName, payload }); + } }) ); return () => { - for (const off of offs) off(); + for (const off of offs) { + off(); + } eventService.unsubscribe([context]); }; // events is meant to be a stable array literal at the call site; 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 04c3c67ac2..524639c8cc 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 @@ -23,17 +23,20 @@ export function useInstanceEventSubscription(sandboxId: string | undefined) { case 'conversation.left': case 'message.created': case 'message.updated': - case 'message.deleted': + case 'message.deleted': { // message.* invalidates the conversations list so last-message // preview and unread counts stay current while the user is on // the list (instance-level presence, not viewing a specific conv). void qc.invalidateQueries({ queryKey: ['conversations', sandboxId] }); break; - case 'bot.status': + } + case 'bot.status': { void qc.invalidateQueries({ queryKey: ['bot-status', sandboxId] }); break; - default: + } + default: { break; + } } }, [qc, sandboxId] From d40b216216de144914eccd1fce57e8e9cc27f2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:10:58 +0200 Subject: [PATCH 53/88] feat(kilo-chat-hooks): create shared package; extract useConversations --- apps/mobile/package.json | 1 + apps/web/package.json | 1 + .../claw/kilo-chat/hooks/useConversations.ts | 135 ++---------------- apps/web/src/contexts/EventServiceContext.tsx | 9 +- packages/kilo-chat-hooks/package.json | 27 ++++ packages/kilo-chat-hooks/src/context.tsx | 26 ++++ packages/kilo-chat-hooks/src/index.ts | 2 + .../kilo-chat-hooks/src/use-conversations.ts | 124 ++++++++++++++++ packages/kilo-chat-hooks/tsconfig.json | 20 +++ pnpm-lock.yaml | 31 ++++ 10 files changed, 251 insertions(+), 125 deletions(-) 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/use-conversations.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/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/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/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..e15161978f --- /dev/null +++ b/packages/kilo-chat-hooks/package.json @@ -0,0 +1,27 @@ +{ + "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..eaf921a29d --- /dev/null +++ b/packages/kilo-chat-hooks/src/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './use-conversations'; 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..faaf06f759 --- /dev/null +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -0,0 +1,124 @@ +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); + } + } + }, + }); +} 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 85dd420104..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: From c829254bb7d0d645dd313aa9024cb366983aeaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:13:59 +0200 Subject: [PATCH 54/88] =?UTF-8?q?feat(kilo-chat-hooks):=20extract=20useMes?= =?UTF-8?q?sages=20=E2=80=94=20base=20query=20+=20optimistic=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../(app)/claw/kilo-chat/hooks/useMessages.ts | 80 +-------- packages/kilo-chat-hooks/package.json | 4 +- packages/kilo-chat-hooks/src/index.ts | 1 + packages/kilo-chat-hooks/src/use-messages.ts | 165 ++++++++++++++++++ 4 files changed, 173 insertions(+), 77 deletions(-) create mode 100644 packages/kilo-chat-hooks/src/use-messages.ts 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..ff5f484f2a 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,10 +1,12 @@ -import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; +export { useMessages, useSendMessage } from '@kilocode/kilo-chat-hooks'; +export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; + +import { useMutation, useQueryClient } 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, @@ -19,8 +21,6 @@ import { useEffect } from 'react'; import { kiloclawConversationContext } from '@kilocode/event-service'; import { toast } from 'sonner'; -const PAGE_SIZE = 50; - function applyReactionAdded( reactions: ReactionSummary[], emoji: string, @@ -108,78 +108,6 @@ function findMessageInCache( 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({ diff --git a/packages/kilo-chat-hooks/package.json b/packages/kilo-chat-hooks/package.json index e15161978f..9d61da112d 100644 --- a/packages/kilo-chat-hooks/package.json +++ b/packages/kilo-chat-hooks/package.json @@ -5,7 +5,9 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", - "exports": { ".": "./src/index.ts" }, + "exports": { + ".": "./src/index.ts" + }, "scripts": { "typecheck": "tsgo --noEmit" }, diff --git a/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts index eaf921a29d..aa0315d79d 100644 --- a/packages/kilo-chat-hooks/src/index.ts +++ b/packages/kilo-chat-hooks/src/index.ts @@ -1,2 +1,3 @@ export * from './context'; export * from './use-conversations'; +export * from './use-messages'; 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..bdcdb70c0c --- /dev/null +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -0,0 +1,165 @@ +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 } from '@kilocode/kilo-chat'; + +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: ['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); + }, + }); +} From aa560afb7dfe1e08755e71de21f204ac03c8de24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:16:04 +0200 Subject: [PATCH 55/88] feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations --- .../(app)/claw/kilo-chat/hooks/useMessages.ts | 267 +----------------- packages/kilo-chat-hooks/src/use-messages.ts | 203 ++++++++++++- 2 files changed, 212 insertions(+), 258 deletions(-) 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 ff5f484f2a..f0cda78522 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,13 +1,20 @@ -export { useMessages, useSendMessage } from '@kilocode/kilo-chat-hooks'; +export { + useMessages, + useSendMessage, + useEditMessage, + useDeleteMessage, + useAddReaction, + useRemoveReaction, + useExecuteAction, +} from '@kilocode/kilo-chat-hooks'; export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query'; import type { KiloChatClient } from '@kilocode/kilo-chat'; import type { Message, ReactionSummary, - EditMessageRequest, MessageCreatedEvent, MessageUpdatedEvent, MessageDeletedEvent, @@ -15,7 +22,6 @@ import type { ActionDeliveryFailedEvent, ReactionAddedEvent, ReactionRemovedEvent, - ExecApprovalDecision, } from '@kilocode/kilo-chat'; import { useEffect } from 'react'; import { kiloclawConversationContext } from '@kilocode/event-service'; @@ -50,259 +56,6 @@ function applyReactionRemoved( .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 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. diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index bdcdb70c0c..f948fcd090 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -1,7 +1,13 @@ 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 } from '@kilocode/kilo-chat'; +import type { + Message, + ReactionSummary, + CreateMessageRequest, + EditMessageRequest, + ExecApprovalDecision, +} from '@kilocode/kilo-chat'; export const PAGE_SIZE = 50; @@ -163,3 +169,198 @@ export function useSendMessage( }, }); } + +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); + }, + }); +} From 1dd065a63a3f0f294c5254ec8f518aa828c00ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:19:39 +0200 Subject: [PATCH 56/88] 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. --- .../claw/kilo-chat/components/MessageArea.tsx | 4 +- .../(app)/claw/kilo-chat/hooks/useMessages.ts | 239 +----------------- packages/kilo-chat-hooks/src/use-messages.ts | 201 +++++++++++++++ 3 files changed, 205 insertions(+), 239 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 e8d373a230..4f5d96e97e 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, msg => + toast.error(msg) + ); const sendTyping = useTypingSender(kiloChatClient, conversationId); const markRead = useMarkConversationRead(kiloChatClient); 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 f0cda78522..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 @@ -6,243 +6,6 @@ export { useAddReaction, useRemoveReaction, useExecuteAction, + useMessageCacheUpdater, } from '@kilocode/kilo-chat-hooks'; export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks'; - -import { useQueryClient } from '@tanstack/react-query'; -import type { InfiniteData } from '@tanstack/react-query'; -import type { KiloChatClient } from '@kilocode/kilo-chat'; -import type { - Message, - ReactionSummary, - MessageCreatedEvent, - MessageUpdatedEvent, - MessageDeletedEvent, - MessageDeliveryFailedEvent, - ActionDeliveryFailedEvent, - ReactionAddedEvent, - ReactionRemovedEvent, -} from '@kilocode/kilo-chat'; -import { useEffect } from 'react'; -import { kiloclawConversationContext } from '@kilocode/event-service'; -import { toast } from 'sonner'; - -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); -} - -/** - * 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]); -} diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index f948fcd090..d0acf0908b 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -7,7 +7,16 @@ import type { CreateMessageRequest, EditMessageRequest, ExecApprovalDecision, + MessageCreatedEvent, + MessageUpdatedEvent, + MessageDeletedEvent, + MessageDeliveryFailedEvent, + ActionDeliveryFailedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, } from '@kilocode/kilo-chat'; +import { useEffect } from 'react'; +import { kiloclawConversationContext } from '@kilocode/event-service'; export const PAGE_SIZE = 50; @@ -364,3 +373,195 @@ export function useExecuteAction( }, }); } + +/** + * 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, + onActionFailed?: (message: 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 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?.("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(onActionDeliveryFailed), + client.onReactionAdded(onReactionAdded), + client.onReactionRemoved(onReactionRemoved), + ]; + return () => { + for (const off of offs) off(); + }; + }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated, onActionFailed]); +} From ad591d9fb0ffbac704808f513a19dea1fd1fe577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:22:14 +0200 Subject: [PATCH 57/88] feat(mobile): wire shared kilo-chat-hooks + platform adapters --- .../kilo-chat/hooks/use-conversations.ts | 11 +++++++ .../kilo-chat/hooks/use-kilo-chat-client.ts | 21 +------------- .../kilo-chat/hooks/use-mark-read.ts | 29 +++++++++++++++++++ .../kilo-chat/hooks/use-messages.ts | 11 +++++++ .../kilo-chat/kilo-chat-provider.tsx | 11 ++++++- 5 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-conversations.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 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-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/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} + + + ); } From 66713ba9df38bc05976c2a8b22c56c8790fb1433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:37:08 +0200 Subject: [PATCH 58/88] 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. --- .../hooks/use-app-active-and-focused.ts | 33 +++++++++++ .../hooks/use-conversation-presence.ts | 29 +-------- .../kilo-chat/hooks/use-event-subscription.ts | 29 ++++----- .../hooks/use-instance-event-subscription.ts | 59 +++++++------------ .../kilo-chat/hooks/use-instance-presence.ts | 29 +-------- .../claw/kilo-chat/components/MessageArea.tsx | 4 +- .../claw/kilo-chat/hooks/useBotStatus.ts | 7 +-- packages/kilo-chat-hooks/src/index.ts | 1 + packages/kilo-chat-hooks/src/query-keys.ts | 17 ++++++ .../kilo-chat-hooks/src/use-conversations.ts | 18 +++--- packages/kilo-chat-hooks/src/use-messages.ts | 26 ++++---- 11 files changed, 121 insertions(+), 131 deletions(-) create mode 100644 apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts create mode 100644 packages/kilo-chat-hooks/src/query-keys.ts 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-conversation-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts index 437534318f..ed2b68dacc 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,38 +1,15 @@ -import { useCallback, useEffect, useState } from 'react'; -import { AppState } from 'react-native'; -import { useFocusEffect } from 'expo-router'; - 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 [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); - }; - }, []) - ); - + const activeAndFocused = useAppActiveAndFocused(); usePresenceSubscription( sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, - Boolean(sandboxId && conversationId) && appActive && focused + Boolean(sandboxId && conversationId) && activeAndFocused ); } 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 index e4ae65fa31..e3b783bdad 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts @@ -2,10 +2,14 @@ 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, - events: readonly string[], - onEvent: (event: { event: string; payload: unknown }) => void + eventName: string, + onEvent: (payload: unknown) => void ) { const eventService = useEventServiceClient(); useEffect(() => { @@ -13,21 +17,14 @@ export function useEventSubscription( return undefined; } eventService.subscribe([context]); - const offs = events.map(eventName => - eventService.on(eventName, (ctx, payload) => { - if (ctx === context) { - onEvent({ event: eventName, payload }); - } - }) - ); - return () => { - for (const off of offs) { - off(); + const off = eventService.on(eventName, (ctx, payload) => { + if (ctx === context) { + onEvent(payload); } + }); + return () => { + off(); eventService.unsubscribe([context]); }; - // events is meant to be a stable array literal at the call site; - // join to use as a dependency without forcing memoization on callers. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventService, context, events.join('|'), onEvent]); + }, [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 index 524639c8cc..b2c9036b6d 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 @@ -2,48 +2,29 @@ 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'; -const INSTANCE_EVENTS = [ - 'conversation.created', - 'conversation.left', - 'message.created', - 'message.updated', - 'message.deleted', - 'bot.status', -] as const; - export function useInstanceEventSubscription(sandboxId: string | undefined) { const qc = useQueryClient(); - const onEvent = useCallback( - ({ event }: { event: string; payload: unknown }) => { - switch (event) { - case 'conversation.created': - case 'conversation.left': - case 'message.created': - case 'message.updated': - case 'message.deleted': { - // message.* invalidates the conversations list so last-message - // preview and unread counts stay current while the user is on - // the list (instance-level presence, not viewing a specific conv). - void qc.invalidateQueries({ queryKey: ['conversations', sandboxId] }); - break; - } - case 'bot.status': { - void qc.invalidateQueries({ queryKey: ['bot-status', sandboxId] }); - break; - } - default: { - break; - } - } - }, - [qc, sandboxId] - ); - useEventSubscription( - sandboxId ? kiloclawInstanceContext(sandboxId) : null, - INSTANCE_EVENTS, - onEvent - ); + const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null; + + // message.* / conversation.* invalidate the conversations list so + // last-message preview and unread counts stay current while the user is on + // the list (instance-level presence, not viewing a specific conv). + 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, 'message.created', invalidateConversations); + useEventSubscription(ctx, 'message.updated', invalidateConversations); + useEventSubscription(ctx, 'message.deleted', 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 index e81be747fe..1c04d9eaca 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,35 +1,12 @@ -import { useCallback, useEffect, useState } from 'react'; -import { AppState } from 'react-native'; -import { useFocusEffect } from 'expo-router'; - 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 [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); - }; - }, []) - ); - + const activeAndFocused = useAppActiveAndFocused(); usePresenceSubscription( sandboxId ? presenceContextForInstance(sandboxId) : null, - Boolean(sandboxId) && appActive && focused + Boolean(sandboxId) && activeAndFocused ); } 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 4f5d96e97e..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,8 +127,8 @@ 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, msg => - toast.error(msg) + useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember, () => + toast.error("Couldn't reach the bot — please try again") ); const sendTyping = useTypingSender(kiloChatClient, conversationId); 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/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts index aa0315d79d..599b343147 100644 --- a/packages/kilo-chat-hooks/src/index.ts +++ b/packages/kilo-chat-hooks/src/index.ts @@ -1,3 +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 index faaf06f759..430e797fc0 100644 --- a/packages/kilo-chat-hooks/src/use-conversations.ts +++ b/packages/kilo-chat-hooks/src/use-conversations.ts @@ -3,11 +3,13 @@ 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: ['kilo-chat', 'conversations', sandboxId], + queryKey: conversationsKey(sandboxId), queryFn: ({ pageParam }) => client.listConversations({ sandboxId: sandboxId ?? undefined, @@ -26,7 +28,7 @@ export function useConversations(client: KiloChatClient, sandboxId: string | nul export function useConversationDetail(client: KiloChatClient, conversationId: string | null) { return useQuery({ - queryKey: ['kilo-chat', 'conversation', conversationId], + queryKey: conversationKey(conversationId), queryFn: () => client.getConversation(conversationId ?? ''), enabled: !!conversationId, }); @@ -37,7 +39,7 @@ export function useCreateConversation(client: KiloChatClient) { return useMutation({ mutationFn: (req: CreateConversationRequest) => client.createConversation(req), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); }, }); } @@ -48,7 +50,7 @@ export function useRenameConversation(client: KiloChatClient) { mutationFn: ({ conversationId, title }: { conversationId: string; title: string }) => client.renameConversation(conversationId, { title }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] }); + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); }, }); } @@ -58,9 +60,9 @@ export function useLeaveConversation(client: KiloChatClient) { 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'] }); + queryClient.removeQueries({ queryKey: conversationKey(conversationId) }); + queryClient.removeQueries({ queryKey: messagesKey(conversationId) }); + void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() }); }, }); } @@ -104,7 +106,7 @@ export function useMarkConversationRead(client: KiloChatClient) { onMutate: conversationId => { // Optimistically set lastReadAt = now in all cached conversation lists const now = Date.now(); - const queryKey = ['kilo-chat', 'conversations']; + const queryKey = conversationsKeyAll(); const previous = queryClient.getQueriesData({ queryKey }); queryClient.setQueriesData({ queryKey }, old => updateConversationPages(old, c => diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts index d0acf0908b..b2302e1903 100644 --- a/packages/kilo-chat-hooks/src/use-messages.ts +++ b/packages/kilo-chat-hooks/src/use-messages.ts @@ -18,6 +18,8 @@ import type { import { useEffect } from 'react'; import { kiloclawConversationContext } from '@kilocode/event-service'; +import { messagesKey } from './query-keys'; + export const PAGE_SIZE = 50; export function applyReactionAdded( @@ -109,7 +111,7 @@ export function findMessageInCache( export function useMessages(client: KiloChatClient, conversationId: string | null) { return useInfiniteQuery({ - queryKey: ['kilo-chat', 'messages', conversationId], + queryKey: messagesKey(conversationId), queryFn: async ({ pageParam }) => { return client.listMessages(conversationId ?? '', { before: pageParam, limit: PAGE_SIZE }); }, @@ -138,7 +140,7 @@ export function useSendMessage( mutationFn: (req: SendMessageVariables) => client.sendMessage(req), onMutate: async (variables: SendMessageVariables) => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const pendingId = `pending-${variables.clientId}`; const optimisticMessage: Message = { @@ -186,7 +188,7 @@ export function useEditMessage(client: KiloChatClient, conversationId: string | client.editMessage(messageId, req), onMutate: async variables => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { @@ -218,7 +220,7 @@ export function useDeleteMessage(client: KiloChatClient, conversationId: string client.deleteMessage(messageId, { conversationId }), onMutate: async variables => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { @@ -250,7 +252,7 @@ export function useAddReaction( client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }), onMutate: async variables => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { @@ -289,7 +291,7 @@ export function useRemoveReaction( client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }), onMutate: async variables => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); queryClient.setQueryData>(queryKey, old => { @@ -335,7 +337,7 @@ export function useExecuteAction( }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }), onMutate: async variables => { if (!conversationId) return; - const queryKey = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); await queryClient.cancelQueries({ queryKey }); const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId); // Optimistically mark the action as resolved @@ -395,13 +397,17 @@ export function useMessageCacheUpdater( // 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, - onActionFailed?: (message: 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 = ['kilo-chat', 'messages', conversationId]; + const queryKey = messagesKey(conversationId); const expectedContext = kiloclawConversationContext(sandboxId, conversationId); const onCreated = (ctx: string, e: MessageCreatedEvent) => { @@ -511,7 +517,7 @@ export function useMessageCacheUpdater( ), }; }); - onActionFailed?.("Couldn't reach the bot — please try again"); + onActionFailed?.(); }; const onReactionAdded = (ctx: string, e: ReactionAddedEvent) => { From ffeb4b6d3a62918fc054297bf10590bc4ca55d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:54:00 +0200 Subject: [PATCH 59/88] fix(mobile): subscribe to conversation.* events on instance context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/use-instance-event-subscription.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 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 b2c9036b6d..8b6f57449e 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,9 +10,10 @@ export function useInstanceEventSubscription(sandboxId: string | undefined) { const qc = useQueryClient(); const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null; - // message.* / conversation.* invalidate the conversations list so - // last-message preview and unread counts stay current while the user is on - // the list (instance-level presence, not viewing a specific conv). + // 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]); @@ -23,8 +24,8 @@ export function useInstanceEventSubscription(sandboxId: string | undefined) { useEventSubscription(ctx, 'conversation.created', invalidateConversations); useEventSubscription(ctx, 'conversation.left', invalidateConversations); - useEventSubscription(ctx, 'message.created', invalidateConversations); - useEventSubscription(ctx, 'message.updated', invalidateConversations); - useEventSubscription(ctx, 'message.deleted', invalidateConversations); + useEventSubscription(ctx, 'conversation.renamed', invalidateConversations); + useEventSubscription(ctx, 'conversation.read', invalidateConversations); + useEventSubscription(ctx, 'conversation.activity', invalidateConversations); useEventSubscription(ctx, 'bot.status', invalidateBotStatus); } From c259c9567eb317fd8cd361f93a9e1fd05cfb5392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:03:26 +0200 Subject: [PATCH 60/88] chore(mobile): add @shopify/flash-list dependency Required by the kilo-chat MessageList and ConversationListScreen components. --- apps/mobile/package.json | 1 + pnpm-lock.yaml | 60 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) 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/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 41bb5650fdf6bcd315a6cb7f8d5a5cec9d6035cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:03:26 +0200 Subject: [PATCH 61/88] 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. --- apps/mobile/.env | 2 ++ 1 file changed, 2 insertions(+) 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 From 5fd8c698811e4cb72d19d9d73bbce6f50da80b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:46:50 +0200 Subject: [PATCH 62/88] feat(mobile): add EmptyConversationList --- .../kilo-chat/empty-conversation-list.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx 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'} + + } + /> + + ); +} From a4c1b99cff5cc2b3191b9ae6fcfe557aa7c2134b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:48:06 +0200 Subject: [PATCH 63/88] feat(mobile): add ConversationHeader --- .../components/kilo-chat/conversation-header.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/conversation-header.tsx 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 + } + /> + ); +} From e3b7d04ac89f6a4b95c6ea20eb01f40340419b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:49:00 +0200 Subject: [PATCH 64/88] feat(mobile): add TypingIndicator placeholder --- .../src/components/kilo-chat/typing-indicator.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/typing-indicator.tsx 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… + + ); +} From 0f910d65d902e8ac9e4ede3f59a65dd99872cbda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:50:15 +0200 Subject: [PATCH 65/88] feat(mobile): add MessageInput --- .../components/kilo-chat/message-input.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/message-input.tsx 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} + /> + + + + + ); +} From 682e87b83b15bcda1bb83f77caa85cfed0dd220c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:53:06 +0200 Subject: [PATCH 66/88] feat(mobile): add MessageBubble --- .../components/kilo-chat/message-bubble.tsx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/message-bubble.tsx 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..af63b2189a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -0,0 +1,194 @@ +import { + useAddReaction, + useExecuteAction, + useKiloChatClient, + useRemoveReaction, +} from '@kilocode/kilo-chat-hooks'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { Pressable, Text, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +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} + + + ); + })} + + )} + + ); +} From a0f8c840ae0bbc9618d064b456e9a005270aae1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:55:57 +0200 Subject: [PATCH 67/88] feat(mobile): add MessageList Implement MessageList using FlashList v2 with maintainVisibleContentPosition and startRenderingFromBottom for chat layout; wire fetchOlder via onStartReached. --- .../src/components/kilo-chat/message-list.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/message-list.tsx 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 + } + /> + ); +} From 497613a2cb47c12b9b340c2b356b42ad2f4d5ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 12:58:08 +0200 Subject: [PATCH 68/88] feat(mobile): add ConversationScreen --- .../kilo-chat/conversation-screen.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/conversation-screen.tsx 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..21470d0e95 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -0,0 +1,71 @@ +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 ( + + + + + + + + + ); +} From 0d78ce70f915b6e8df3efeb83993538ea3b99367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:01:20 +0200 Subject: [PATCH 69/88] feat(mobile): add ConversationListScreen --- .../kilo-chat/conversation-list-screen.tsx | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx 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..a0b9d01953 --- /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 unknown as Href); + } + + function handleCreateAndNavigate() { + createConversation.mutate( + { sandboxId }, + { + onSuccess: result => { + // Route lands in PR 5d (Task 47) + router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as unknown as Href); + }, + } + ); + } + + return ( + + + + c.conversationId} + renderItem={({ item }) => } + ListEmptyComponent={ + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + onEndReached={() => { + void fetchNextPage(); + }} + onEndReachedThreshold={0.5} + /> + + + ); +} From 1bf1b34a4897ddd093d508ac3cd85b612b222eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:16:38 +0200 Subject: [PATCH 70/88] 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` --- .../kilo-chat/conversation-list-screen.tsx | 4 ++-- .../components/kilo-chat/conversation-screen.tsx | 3 ++- .../src/components/kilo-chat/message-bubble.tsx | 13 +++++-------- 3 files changed, 9 insertions(+), 11 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 a0b9d01953..029e6c5e64 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -79,7 +79,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { function handleRowPress(conversationId: string) { // Route lands in PR 5d (Task 47) - router.push(`/(app)/chat/${sandboxId}/${conversationId}` as unknown as Href); + router.push(`/(app)/chat/${sandboxId}/${conversationId}` as Href); } function handleCreateAndNavigate() { @@ -88,7 +88,7 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { { onSuccess: result => { // Route lands in PR 5d (Task 47) - router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as unknown as Href); + router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as Href); }, } ); diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx index 21470d0e95..ac42c770a8 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 @@ +import * as Crypto from 'expo-crypto'; import { useCallback } from 'react'; import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { useFocusEffect } from 'expo-router'; @@ -33,7 +34,7 @@ export function ConversationScreen({ sandboxId, conversationId, conversationTitl sendMutation.mutate({ conversationId, content: [{ type: 'text', text }], - clientId: crypto.randomUUID(), + clientId: Crypto.randomUUID(), }); }, [sendMutation, conversationId] diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx index af63b2189a..211264261a 100644 --- a/apps/mobile/src/components/kilo-chat/message-bubble.tsx +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -1,14 +1,11 @@ -import { - useAddReaction, - useExecuteAction, - useKiloChatClient, - useRemoveReaction, -} from '@kilocode/kilo-chat-hooks'; +import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; -import { Pressable, Text, View } from 'react-native'; +import { Pressable, View } from 'react-native'; -import { Button } from '@/components/ui/button'; 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 = { From ce577c4e9b7ec88eaefa7bdba088c111d1f2b4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:24:37 +0200 Subject: [PATCH 71/88] feat(mobile): add chat sandbox stack layout --- apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/_layout.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 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 ; +} From a25a8df7722b3badb904e5f28710ff1f44a37f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:24:40 +0200 Subject: [PATCH 72/88] feat(mobile): add conversation list route --- apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/index.tsx 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 ; +} From 4ed5cb832617f46a52ebd9f5436fa94953bb2d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:24:43 +0200 Subject: [PATCH 73/88] feat(mobile): add conversation message route --- .../chat/[sandbox-id]/[conversation-id].tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/mobile/src/app/(app)/chat/[sandbox-id]/[conversation-id].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 ( + + ); +} From 381ad05f9ad1bddd689a301dcfb9631e88b5e144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:28:28 +0200 Subject: [PATCH 74/88] feat(mobile): wire chat deep links and active-conversation suppression --- .../kilo-chat/conversation-screen.tsx | 6 ++- apps/mobile/src/components/kiloclaw/chat.tsx | 6 +-- .../hooks/use-unread-counts-invalidation.ts | 2 +- apps/mobile/src/lib/notifications.ts | 38 +++++++++---------- 4 files changed, 26 insertions(+), 26 deletions(-) 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 626cde8230..f38ea731a4 100644 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ b/apps/mobile/src/components/kiloclaw/chat.tsx @@ -18,7 +18,7 @@ import { useStreamChatTheme } from '@/components/kiloclaw/chat-theme'; 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 { parseNotificationData } from '@/lib/notifications'; import { useTRPC } from '@/lib/trpc'; type KiloClawChatProps = { @@ -72,7 +72,6 @@ export function KiloClawChat({ useFocusEffect( useCallback(() => { isFocusedRef.current = true; - setActiveChatInstance(instanceId); setLastActiveInstance(instanceId); markChatRead({ channelId: instanceId }); @@ -81,14 +80,13 @@ 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?.type === 'chat' && data.instanceId === instanceId) { + if (data?.type === 'chat.message' && data.sandboxId === instanceId) { markChatRead({ channelId: instanceId }); } }); 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 c20858c069..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,30 +14,24 @@ 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; } -// 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), -}); - -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; } @@ -62,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 && data.instanceId === activeChatInstanceId) { + if ( + data?.type === 'chat.message' && + activeChatLocation?.sandboxId === data.sandboxId && + activeChatLocation.conversationId === data.conversationId + ) { return suppressed; } @@ -87,7 +85,7 @@ export function setupNotificationResponseHandler() { const data = parseNotificationData(response.notification.request.content.data); if (data) { - const path = `/(app)/chat/${data.instanceId}`; + 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 { @@ -109,7 +107,7 @@ export function checkInitialNotification(): void { } const data = parseNotificationData(response.notification.request.content.data); if (data) { - pendingNotificationLink = `/(app)/chat/${data.instanceId}`; + pendingNotificationLink = `/(app)/chat/${data.sandboxId}/${data.conversationId}`; } } From d4e68ccd9687d74fc3206472ec39da7a97042c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:42:04 +0200 Subject: [PATCH 75/88] fix(mobile): clear correct badge bucket on legacy chat foreground push --- apps/mobile/src/components/kiloclaw/chat.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/kiloclaw/chat.tsx b/apps/mobile/src/components/kiloclaw/chat.tsx index f38ea731a4..30e17ae7b4 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'; @@ -81,7 +83,9 @@ export function KiloClawChat({ const subscription = Notifications.addNotificationReceivedListener(notification => { const data = parseNotificationData(notification.request.content.data); if (data?.type === 'chat.message' && data.sandboxId === instanceId) { - markChatRead({ channelId: instanceId }); + markChatRead({ + badgeBucket: badgeBucketForConversation(data.sandboxId, data.conversationId), + }); } }); From 1ea1baf9e963a47985e975684c306d7b201bbdc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:50:00 +0200 Subject: [PATCH 76/88] chore(mobile): delete Stream-based chat components and routes --- apps/mobile/src/app/(app)/_layout.tsx | 1 - .../src/app/(app)/chat/[instance-id].tsx | 25 -- .../src/components/home/kiloclaw-card.tsx | 36 --- .../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 | 298 ------------------ .../kiloclaw/notification-prompt.tsx | 121 ------- .../components/kiloclaw/onboarding-flow.tsx | 2 +- .../lib/hooks/use-kiloclaw-latest-message.ts | 87 ----- .../mobile/src/lib/hooks/use-unread-counts.ts | 12 +- 13 files changed, 10 insertions(+), 798 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 diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 97bbe8abd3..c3d1e1c31d 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -21,7 +21,6 @@ export default function AppLayout() { }} > - (); - 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/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 30e17ae7b4..0000000000 --- a/apps/mobile/src/components/kiloclaw/chat.tsx +++ /dev/null @@ -1,298 +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 { 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 = { channelId: 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 ({ channelId }) => { - await queryClient.cancelQueries({ queryKey: unreadCountsKey }); - const previous = queryClient.getQueryData(unreadCountsKey); - queryClient.setQueryData(unreadCountsKey, old => - (old ?? []).filter(row => row.channelId !== channelId) - ); - 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(() => { - isFocusedRef.current = true; - setLastActiveInstance(instanceId); - markChatRead({ channelId: instanceId }); - - // 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({ channelId: 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/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-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index e69e93fdc9..43e9d8c131 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -5,8 +5,9 @@ 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`. + * a Map keyed by sandboxId for O(1) lookup from dashboard cards. Badge buckets + * use the format `kiloclaw:{sandboxId}:{conversationId}`; counts are summed + * across all conversations belonging to the same sandbox. * * Freshness is driven by invalidations, not polling: * - Foreground chat push → invalidate (see `use-unread-counts-invalidation`). @@ -24,7 +25,12 @@ export function useUnreadCounts() { const byChannel = useMemo(() => { const map = new Map(); for (const row of query.data ?? []) { - map.set(row.channelId, row.badgeCount); + // Badge buckets: `kiloclaw:{sandboxId}:{conversationId}` + const parts = row.badgeBucket.split(':'); + const sandboxId = parts.length >= 2 ? parts[1] : row.badgeBucket; + if (sandboxId) { + map.set(sandboxId, (map.get(sandboxId) ?? 0) + row.badgeCount); + } } return map; }, [query.data]); From bc2af49ee39bc303eea2dcbef975e5088ce23efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:50:16 +0200 Subject: [PATCH 77/88] chore(mobile): remove useStreamChatCredentials hook --- .../src/lib/hooks/use-kiloclaw-queries.ts | 18 ------------------ 1 file changed, 18 deletions(-) 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); From ce803813eac6c5cfbd67909bec279aa885602a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Apr 2026 13:52:22 +0200 Subject: [PATCH 78/88] chore: remove stream-chat deps and RN patch --- apps/mobile/package.json | 2 - package.json | 3 - patches/@gorhom__bottom-sheet@5.1.8.patch | 97 ------- patches/stream-chat-react-native-core.patch | 65 ----- pnpm-lock.yaml | 302 +------------------- 5 files changed, 1 insertion(+), 468 deletions(-) delete mode 100644 patches/@gorhom__bottom-sheet@5.1.8.patch delete mode 100644 patches/stream-chat-react-native-core.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c210d87966..fe47d8cce7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -83,8 +83,6 @@ "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:" 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..989d8d4611 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: @@ -326,12 +317,6 @@ 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 @@ -1604,7 +1589,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 @@ -3939,27 +3924,6 @@ packages: '@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==} @@ -9280,9 +9244,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==} @@ -10873,9 +10834,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 +10868,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==} @@ -12132,11 +12087,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 +12643,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==} @@ -13136,12 +13083,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 +13122,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: @@ -13741,9 +13677,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,66 +13825,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: @@ -14637,9 +14510,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 +14725,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 +14769,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'} @@ -17482,23 +17344,6 @@ snapshots: '@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': @@ -23646,8 +23491,6 @@ snapshots: date-fns@4.1.0: {} - dayjs@1.11.13: {} - dayjs@1.11.20: {} debounce@1.2.1: {} @@ -25362,8 +25205,6 @@ snapshots: indent-string@5.0.0: {} - inherits@2.0.3: {} - inherits@2.0.4: {} ini@1.3.8: {} @@ -25411,8 +25252,6 @@ snapshots: interpret@3.1.1: {} - intl-pluralrules@2.0.1: {} - invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -25746,25 +25585,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 +26236,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: {} @@ -27487,8 +27294,6 @@ snapshots: mime@3.0.0: {} - mime@4.1.0: {} - mimic-fn@1.2.0: {} mimic-fn@2.1.0: {} @@ -28152,11 +27957,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: {} @@ -28670,16 +28470,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 +28516,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 @@ -29542,10 +29327,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,75 +29498,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 @@ -30524,10 +30236,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 +30604,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 +30755,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 From 24370b59a41c523251302390173dcab6e1671ec6 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:01:50 +0200 Subject: [PATCH 79/88] chore(web): remove Stream tRPC procedures --- apps/web/src/routers/kiloclaw-router.ts | 72 ----- .../kiloclaw-send-chat-message.test.ts | 247 ------------------ .../organization-kiloclaw-router.ts | 33 --- 3 files changed, 352 deletions(-) delete mode 100644 apps/web/src/routers/kiloclaw-send-chat-message.test.ts 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(); From 959c2bb7ea54880bf318af52cc9a3c886a00207c 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:01:54 +0200 Subject: [PATCH 80/88] chore(web): delete Stream chat-credentials API route --- .../api/kiloclaw/chat-credentials/route.ts | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 apps/web/src/app/api/kiloclaw/chat-credentials/route.ts 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 }); - } -} From 365d72e3345bd9971f8e76a365784e4104435af9 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:01:58 +0200 Subject: [PATCH 81/88] chore(web): strip Stream methods from kiloclaw clients --- .../lib/kiloclaw/kiloclaw-internal-client.ts | 31 ------------------- .../src/lib/kiloclaw/kiloclaw-user-client.ts | 11 +------ apps/web/src/lib/kiloclaw/types.ts | 8 ----- 3 files changed, 1 insertion(+), 49 deletions(-) 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. */ From b0edac9b5afe22656eed308147ef6470377c8e06 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:02:03 +0200 Subject: [PATCH 82/88] chore(web): replace ChatTab with redirect, drop Stream hooks --- .../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/hooks/useKiloClaw.ts | 10 - 4 files changed, 9 insertions(+), 261 deletions(-) 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/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( From 8db8959edcb9727ae23d8917156378fa81f27865 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:06:08 +0200 Subject: [PATCH 83/88] chore(kiloclaw): delete src/stream-chat directory --- .../kiloclaw/src/stream-chat/client.test.ts | 477 ------------------ services/kiloclaw/src/stream-chat/client.ts | 299 ----------- 2 files changed, 776 deletions(-) delete mode 100644 services/kiloclaw/src/stream-chat/client.test.ts delete mode 100644 services/kiloclaw/src/stream-chat/client.ts 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, - }); - } -} From 8369c3e076dc38812d398199d0db5005db5153ef 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:09:08 +0200 Subject: [PATCH 84/88] chore(kiloclaw): remove Stream injections from instance DO and routes --- .../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 - services/kiloclaw/src/types.ts | 4 - 10 files changed, 513 deletions(-) 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/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; From fedb889078cee54827544181ed2d2ee3e071a534 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:10:02 +0200 Subject: [PATCH 85/88] chore(kiloclaw): remove Stream from controller config-writer --- .../controller/src/config-writer.test.ts | 62 ------------------- .../kiloclaw/controller/src/config-writer.ts | 28 --------- 2 files changed, 90 deletions(-) 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. From dcd02714145f1661d9c1527420509b80a6ffc672 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:10:41 +0200 Subject: [PATCH 86/88] chore(kiloclaw): drop STREAM_CHAT_* secret bindings --- services/kiloclaw/worker-configuration.d.ts | 51 ++++++++++----------- 1 file changed, 24 insertions(+), 27 deletions(-) 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 8fdbc1e2a89498da485810b3375d52464579e22a 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:13:27 +0200 Subject: [PATCH 87/88] chore(web): remove residual Stream CSS and npm deps --- apps/web/package.json | 2 - apps/web/src/app/(app)/claw/claw-chat.css | 153 ------ apps/web/src/app/(app)/claw/layout.tsx | 1 - .../(app)/organizations/[id]/claw/layout.tsx | 1 - pnpm-lock.yaml | 472 ------------------ 5 files changed, 629 deletions(-) delete mode 100644 apps/web/src/app/(app)/claw/claw-chat.css 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 989d8d4611..9e06b1a1e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -802,12 +802,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) @@ -3244,9 +3238,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==} @@ -3915,12 +3906,6 @@ 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==} @@ -6066,30 +6051,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: @@ -6202,19 +6163,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'} @@ -7297,14 +7245,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==} @@ -8001,15 +7941,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: @@ -8398,10 +8329,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} @@ -9664,9 +9591,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'} @@ -10307,10 +10231,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'} @@ -10376,9 +10296,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} @@ -10643,12 +10560,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==} @@ -10762,14 +10673,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'} @@ -11043,11 +10946,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'} @@ -11609,12 +11507,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'} @@ -11645,9 +11537,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==} @@ -11675,9 +11564,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==} @@ -11687,9 +11573,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==} @@ -12905,9 +12788,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==} @@ -13013,12 +12893,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==} @@ -13028,11 +12902,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==} @@ -13048,12 +12917,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==} @@ -13140,11 +13003,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'} @@ -13197,25 +13055,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'} @@ -13825,30 +13670,6 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} - 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==} @@ -14035,9 +13856,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'} @@ -14388,9 +14206,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==} @@ -14456,38 +14271,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'} @@ -16497,8 +16285,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 @@ -17334,14 +17120,6 @@ 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': {} '@hapi/hoek@9.3.0': {} @@ -19618,42 +19396,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 @@ -19848,19 +19590,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 @@ -21416,15 +21145,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': {} @@ -22043,13 +21763,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 @@ -22518,8 +22231,6 @@ snapshots: asynckit@0.4.0: {} - attr-accept@2.2.5: {} - auto-bind@5.0.1: {} available-typed-arrays@1.0.7: @@ -23845,8 +23556,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - emojis-list@3.0.0: {} encodeurl@1.0.2: {} @@ -24595,10 +24304,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: @@ -24693,8 +24398,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 @@ -24975,17 +24678,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 @@ -25150,12 +24842,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 @@ -25385,10 +25071,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: @@ -26464,10 +26146,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: @@ -26494,9 +26172,6 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.deburr@4.1.0: - optional: true - lodash.flattendeep@4.4.0: {} lodash.includes@4.3.0: {} @@ -26515,16 +26190,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: @@ -28228,12 +27899,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: @@ -28377,23 +28042,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: {} @@ -28418,24 +28072,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): @@ -28583,15 +28219,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 @@ -28662,27 +28289,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: {} @@ -29498,64 +29109,6 @@ snapshots: stream-buffers@2.2.0: {} - 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 @@ -29740,8 +29293,6 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tabbable@6.4.0: {} - tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -30084,10 +29635,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 @@ -30183,29 +29730,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 From 687d703247ccdb57fd1fb6da0faf6db3875457e6 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:22:27 +0200 Subject: [PATCH 88/88] chore(mobile): drop unused exports and deps flagged by knip --- apps/mobile/package.json | 4 +-- .../kilo-chat/hooks/use-conversations.ts | 6 ---- .../kilo-chat/hooks/use-messages.ts | 12 +------ .../kilo-chat/kilo-chat-provider.tsx | 31 +++++-------------- apps/mobile/src/lib/last-active-instance.ts | 5 --- pnpm-lock.yaml | 16 ---------- 6 files changed, 9 insertions(+), 65 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fe47d8cce7..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", @@ -84,8 +83,7 @@ "react-native-worklets": "0.7.2", "sonner-native": "^0.23.1", "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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 9e06b1a1e9..5fd5cf94ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,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) @@ -323,9 +320,6 @@ importers: tailwindcss: specifier: ^4.2.2 version: 4.2.2 - zod: - specifier: 'catalog:' - version: 4.3.6 devDependencies: '@sentry/cli': specifier: ^3.3.4 @@ -9962,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: @@ -23958,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)