From 3f792a3491d24064f41a041ff4240716360f78fd Mon Sep 17 00:00:00 2001 From: Emilie Lima Schario <14057155+emilieschario@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:24:42 -0400 Subject: [PATCH 01/25] feat(kiloclaw): add redeploy URL to Google setup completion message (#1792) --- kiloclaw/google-setup/setup.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/kiloclaw/google-setup/setup.mjs b/kiloclaw/google-setup/setup.mjs index da217862e0..eb628790d4 100644 --- a/kiloclaw/google-setup/setup.mjs +++ b/kiloclaw/google-setup/setup.mjs @@ -708,6 +708,7 @@ console.log(' Your bot can now use Gmail, Calendar, Drive, Docs, Sheets, and mo console.log(''); console.log(' Next steps:'); console.log(' 1. Redeploy your kiloclaw instance to activate Google services'); +console.log(' Go to: https://app.kilo.ai/claw#settings'); if (pushSetupOk) { console.log(' 2. Gmail push notifications have been enabled automatically.'); } else { From f639f799ad55c9ce0665cac3573ca4c6e6ca8d19 Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl <61753986+pedroheyerdahl@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:48:23 -0300 Subject: [PATCH 02/25] fix(tracking): only resolve signup product when callbackPath was explicitly provided (#1793) * fix(tracking): only resolve signup product when callbackPath was explicitly provided * style: apply oxfmt formatting to after-sign-in route --- src/app/users/after-sign-in/route.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/users/after-sign-in/route.tsx b/src/app/users/after-sign-in/route.tsx index a241a2912a..b898fb61be 100644 --- a/src/app/users/after-sign-in/route.tsx +++ b/src/app/users/after-sign-in/route.tsx @@ -49,7 +49,10 @@ export async function GET(request: NextRequest) { // callbackPath query param, so the value cannot be user-tampered. This // runs exactly once per signup because has_validation_stytch is set // after account verification completes. - const product = resolveSignupProduct(responsePath, !!url.searchParams.get('source')); + const product = resolveSignupProduct( + callbackPath && isValidCallbackPath(callbackPath) ? responsePath : null, + !!url.searchParams.get('source') + ); if (product) { PostHogClient().capture({ distinctId: user.google_user_email, From e847e79cabc73c9595b99a11f73a67bf6c33aa55 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 31 Mar 2026 21:37:07 +0200 Subject: [PATCH 03/25] feat(cloud-agent): add PostHog tracking for remote session events (#1767) Add onResolved callback to CloudAgentSession that fires after transport resolution, giving the session manager access to the authoritative ResolvedSession. Use this to track an ActiveSessionType ('cloud-agent' | 'cli') and emit PostHog analytics events (remote_session_opened, remote_session_message_sent) for live CLI sessions. --- .../cloud-agent-next/CloudAgentProvider.tsx | 17 +++++++++++++++++ src/lib/cloud-agent-sdk/session-manager.ts | 19 +++++++++++++++++++ src/lib/cloud-agent-sdk/session.ts | 2 ++ 3 files changed, 38 insertions(+) diff --git a/src/components/cloud-agent-next/CloudAgentProvider.tsx b/src/components/cloud-agent-next/CloudAgentProvider.tsx index ffede7dc24..38ea81721e 100644 --- a/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -13,6 +13,7 @@ import { type CloudAgentSessionId, } from '@/lib/cloud-agent-sdk'; import { SESSION_INGEST_WS_URL } from '@/lib/constants'; +import { usePostHog } from 'posthog-js/react'; const ManagerContext = createContext(null); @@ -24,6 +25,9 @@ type CloudAgentProviderProps = { export function CloudAgentProvider({ children, organizationId }: CloudAgentProviderProps) { const storeRef = useRef(createStore()); const trpcClient = useRawTRPCClient(); + const posthog = usePostHog(); + const posthogRef = useRef(posthog); + posthogRef.current = posthog; // Create manager once per provider instance. // trpcClient is stable (from context); organizationId is stable per provider mount. @@ -259,6 +263,19 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi window.history.replaceState(window.history.state, '', url.toString()); } }, + + onRemoteSessionOpened: ({ kiloSessionId }) => { + posthogRef.current?.capture('remote_session_opened', { + feature: 'remote-session', + kilo_session_id: kiloSessionId, + }); + }, + onRemoteSessionMessageSent: ({ kiloSessionId }) => { + posthogRef.current?.capture('remote_session_message_sent', { + feature: 'remote-session', + kilo_session_id: kiloSessionId, + }); + }, }); } diff --git a/src/lib/cloud-agent-sdk/session-manager.ts b/src/lib/cloud-agent-sdk/session-manager.ts index d2c732d22c..307da0b400 100644 --- a/src/lib/cloud-agent-sdk/session-manager.ts +++ b/src/lib/cloud-agent-sdk/session-manager.ts @@ -40,6 +40,7 @@ type SessionConfig = { model: string; variant?: string | null; }; +type ActiveSessionType = 'cloud-agent' | 'cli'; type StandaloneQuestion = { requestId: string; questions: QuestionInfo[] }; type StandalonePermission = { requestId: string; @@ -95,6 +96,8 @@ type SessionManagerConfig = { onComplete?: () => void; onBranchChanged?: (branch: string) => void; onSendFailed?: (messageText: string) => void; + onRemoteSessionOpened?: (data: { kiloSessionId: KiloSessionId }) => void; + onRemoteSessionMessageSent?: (data: { kiloSessionId: KiloSessionId }) => void; }; // Writable/read-only atom aliases for the public atoms record @@ -299,6 +302,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { // Private mutable state let activeSessionId: KiloSessionId | null = null; let currentSession: CloudAgentSession | null = null; + let activeSessionType: ActiveSessionType | null = null; let stateUnsub: (() => void) | null = null; let indicatorTimer: ReturnType | null = null; @@ -418,6 +422,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { async function switchSession(kiloSessionId: KiloSessionId): Promise { activeSessionId = kiloSessionId; + activeSessionType = null; stateUnsub?.(); stateUnsub = null; currentSession?.destroy(); @@ -500,6 +505,10 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { const ap = store.get(activePermissionAtom); if (ap?.requestId === requestId) store.set(activePermissionAtom, null); }, + onResolved: resolved => { + if (resolved.cloudAgentSessionId) activeSessionType = 'cloud-agent'; + else if (resolved.isLive) activeSessionType = 'cli'; + }, onBranchChanged: branch => { const currentFetched = store.get(fetchedSessionDataAtom); if (currentFetched) { @@ -538,6 +547,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { // Fallback: clear loading when events flow even if no root // session.created was replayed (e.g. CLI snapshot failure). store.set(isLoadingAtom, false); + if (activeSessionType === 'cli') { + config.onRemoteSessionOpened?.({ kiloSessionId }); + } }, }); session.connect(); @@ -567,12 +579,18 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } satisfies StoredMessage); try { if (!currentSession) throw new Error('No active session'); + // Snapshot before await — switchSession() can overwrite these while send is in flight. + const sessionType = activeSessionType; + const kiloSessionId = activeSessionId; await currentSession.send({ prompt: payload.prompt, mode: payload.mode, model: payload.model, variant: payload.variant, }); + if (sessionType === 'cli' && kiloSessionId) { + config.onRemoteSessionMessageSent?.({ kiloSessionId }); + } } catch (err) { store.set(optimisticMessageAtom, null); store.set(failedPromptAtom, payload.prompt); @@ -635,6 +653,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } clearAllAtoms(); activeSessionId = null; + activeSessionType = null; } return { diff --git a/src/lib/cloud-agent-sdk/session.ts b/src/lib/cloud-agent-sdk/session.ts index afa8a5b9c6..aa89e7b750 100644 --- a/src/lib/cloud-agent-sdk/session.ts +++ b/src/lib/cloud-agent-sdk/session.ts @@ -42,6 +42,7 @@ type CloudAgentSessionConfig = { ) => void; onPermissionResolved?: (requestId: string) => void; onBranchChanged?: (branch: string) => void; + onResolved?: (resolved: ResolvedSession) => void; onSessionCreated?: (info: SessionInfo) => void; onSessionUpdated?: (info: SessionInfo) => void; onEvent?: (event: NormalizedEvent) => void; @@ -225,6 +226,7 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes if (expectedGeneration !== connectGeneration) return; console.log('[cli-debug] resolveAndConnect: resolved=%o', resolved); + config.onResolved?.(resolved); let factory: TransportFactory; try { From c4c252024320ce9fbbc23d7f0e61c55cf03e34d4 Mon Sep 17 00:00:00 2001 From: Florian Hines Date: Tue, 31 Mar 2026 16:46:08 -0500 Subject: [PATCH 04/25] fix(kiloclaw): pass instanceId to worker for instance-keyed DO routing (#1809) * fix(kiloclaw): pass instanceId to worker for instance-keyed DO routing KiloClawUserClient routes (restartMachine, getConfig, getStatus, getChatCredentials) and several admin/billing routes were not passing instanceId to the worker, causing requests for ki_-prefixed instances to resolve the wrong (empty) DO keyed by userId instead of instanceId. Also fixes: admin CLI run start/status, instance-lifecycle auto-resume, and all four billing lifecycle cron sweeps (stop/destroy). * fix: encode instanceId in KiloClawUserClient URL --- .../api/kiloclaw/chat-credentials/route.ts | 7 ++++- src/app/api/kiloclaw/status/route.ts | 7 ++++- src/lib/kiloclaw/billing-lifecycle-cron.ts | 29 ++++++++++++++++--- src/lib/kiloclaw/instance-lifecycle.ts | 12 ++++---- src/lib/kiloclaw/kiloclaw-user-client.ts | 11 +++++-- .../admin-kiloclaw-instances-router.ts | 16 +++++++--- src/routers/kiloclaw-router.ts | 18 +++++++++--- 7 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/app/api/kiloclaw/chat-credentials/route.ts b/src/app/api/kiloclaw/chat-credentials/route.ts index f38fffa2bf..f05116f353 100644 --- a/src/app/api/kiloclaw/chat-credentials/route.ts +++ b/src/app/api/kiloclaw/chat-credentials/route.ts @@ -5,6 +5,7 @@ import { KiloClawUserClient } from '@/lib/kiloclaw/kiloclaw-user-client'; import { KiloClawApiError } from '@/lib/kiloclaw/kiloclaw-internal-client'; import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; import { requireKiloClawAccess } from '@/lib/kiloclaw/access-gate'; +import { getActiveInstance, workerInstanceId } from '@/lib/kiloclaw/instance-registry'; export async function GET() { const { user, authFailedResponse } = await getUserFromAuth({ @@ -22,11 +23,15 @@ export async function GET() { } try { + const instance = await getActiveInstance(user.id); const token = generateApiToken(user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes, }); const client = new KiloClawUserClient(token); - const creds = await client.getChatCredentials({ userId: user.id }); + 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; diff --git a/src/app/api/kiloclaw/status/route.ts b/src/app/api/kiloclaw/status/route.ts index 5131ed6dce..bd161ac7bd 100644 --- a/src/app/api/kiloclaw/status/route.ts +++ b/src/app/api/kiloclaw/status/route.ts @@ -3,6 +3,7 @@ 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 { getActiveInstance, workerInstanceId } from '@/lib/kiloclaw/instance-registry'; export async function GET() { const { user, authFailedResponse } = await getUserFromAuth({ @@ -11,11 +12,15 @@ export async function GET() { if (authFailedResponse) return authFailedResponse; try { + const instance = await getActiveInstance(user.id); const token = generateApiToken(user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes, }); const client = new KiloClawUserClient(token); - const status = await client.getStatus({ userId: user.id }); + const status = await client.getStatus({ + userId: user.id, + instanceId: workerInstanceId(instance), + }); return NextResponse.json(status); } catch (err) { const status = err instanceof KiloClawApiError ? err.statusCode : 502; diff --git a/src/lib/kiloclaw/billing-lifecycle-cron.ts b/src/lib/kiloclaw/billing-lifecycle-cron.ts index ba54ebb701..37be4772aa 100644 --- a/src/lib/kiloclaw/billing-lifecycle-cron.ts +++ b/src/lib/kiloclaw/billing-lifecycle-cron.ts @@ -22,6 +22,7 @@ import type { import type { TemplateName } from '@/lib/email'; import { send as sendEmail } from '@/lib/email'; import { KiloClawInternalClient, KiloClawApiError } from '@/lib/kiloclaw/kiloclaw-internal-client'; +import { workerInstanceId } from '@/lib/kiloclaw/instance-registry'; import { autoResumeIfSuspended, ensureAutoIntroSchedule } from '@/lib/kiloclaw/stripe-handlers'; import { KILOCLAW_PLAN_COST_MICRODOLLARS, @@ -675,10 +676,12 @@ export async function runKiloClawBillingLifecycleCron( id: kiloclaw_subscriptions.id, user_id: kiloclaw_subscriptions.user_id, instance_id: kiloclaw_subscriptions.instance_id, + sandbox_id: kiloclaw_instances.sandbox_id, email: kilocode_users.google_user_email, }) .from(kiloclaw_subscriptions) .innerJoin(kilocode_users, eq(kiloclaw_subscriptions.user_id, kilocode_users.id)) + .leftJoin(kiloclaw_instances, eq(kiloclaw_subscriptions.instance_id, kiloclaw_instances.id)) .where( and( eq(kiloclaw_subscriptions.status, 'trialing'), @@ -696,7 +699,10 @@ export async function runKiloClawBillingLifecycleCron( // with instance_id=null has nothing to stop. if (row.instance_id) try { - await client.stop(row.user_id, row.instance_id); + await client.stop( + row.user_id, + workerInstanceId({ id: row.instance_id, sandbox_id: row.sandbox_id ?? undefined }) + ); } catch (stopError) { const isExpected = stopError instanceof KiloClawApiError && @@ -754,10 +760,12 @@ export async function runKiloClawBillingLifecycleCron( id: kiloclaw_subscriptions.id, user_id: kiloclaw_subscriptions.user_id, instance_id: kiloclaw_subscriptions.instance_id, + sandbox_id: kiloclaw_instances.sandbox_id, email: kilocode_users.google_user_email, }) .from(kiloclaw_subscriptions) .innerJoin(kilocode_users, eq(kiloclaw_subscriptions.user_id, kilocode_users.id)) + .leftJoin(kiloclaw_instances, eq(kiloclaw_subscriptions.instance_id, kiloclaw_instances.id)) .where( and( eq(kiloclaw_subscriptions.status, 'canceled'), @@ -770,7 +778,10 @@ export async function runKiloClawBillingLifecycleCron( try { if (row.instance_id) try { - await client.stop(row.user_id, row.instance_id); + await client.stop( + row.user_id, + workerInstanceId({ id: row.instance_id, sandbox_id: row.sandbox_id ?? undefined }) + ); } catch (stopError) { const isExpected = stopError instanceof KiloClawApiError && @@ -871,10 +882,12 @@ export async function runKiloClawBillingLifecycleCron( id: kiloclaw_subscriptions.id, user_id: kiloclaw_subscriptions.user_id, instance_id: kiloclaw_subscriptions.instance_id, + sandbox_id: kiloclaw_instances.sandbox_id, email: kilocode_users.google_user_email, }) .from(kiloclaw_subscriptions) .innerJoin(kilocode_users, eq(kiloclaw_subscriptions.user_id, kilocode_users.id)) + .leftJoin(kiloclaw_instances, eq(kiloclaw_subscriptions.instance_id, kiloclaw_instances.id)) .where( and( lt(kiloclaw_subscriptions.destruction_deadline, now), @@ -886,7 +899,10 @@ export async function runKiloClawBillingLifecycleCron( try { if (row.instance_id) try { - await client.destroy(row.user_id, row.instance_id); + await client.destroy( + row.user_id, + workerInstanceId({ id: row.instance_id, sandbox_id: row.sandbox_id ?? undefined }) + ); } catch (destroyError) { const isExpected = destroyError instanceof KiloClawApiError && @@ -961,10 +977,12 @@ export async function runKiloClawBillingLifecycleCron( id: kiloclaw_subscriptions.id, user_id: kiloclaw_subscriptions.user_id, instance_id: kiloclaw_subscriptions.instance_id, + sandbox_id: kiloclaw_instances.sandbox_id, email: kilocode_users.google_user_email, }) .from(kiloclaw_subscriptions) .innerJoin(kilocode_users, eq(kiloclaw_subscriptions.user_id, kilocode_users.id)) + .leftJoin(kiloclaw_instances, eq(kiloclaw_subscriptions.instance_id, kiloclaw_instances.id)) .where( and( eq(kiloclaw_subscriptions.status, 'past_due'), @@ -977,7 +995,10 @@ export async function runKiloClawBillingLifecycleCron( try { if (row.instance_id) try { - await client.stop(row.user_id, row.instance_id); + await client.stop( + row.user_id, + workerInstanceId({ id: row.instance_id, sandbox_id: row.sandbox_id ?? undefined }) + ); } catch (stopError) { const isExpected = stopError instanceof KiloClawApiError && diff --git a/src/lib/kiloclaw/instance-lifecycle.ts b/src/lib/kiloclaw/instance-lifecycle.ts index 07807ab852..7e5802d710 100644 --- a/src/lib/kiloclaw/instance-lifecycle.ts +++ b/src/lib/kiloclaw/instance-lifecycle.ts @@ -10,6 +10,7 @@ import { } from '@kilocode/db/schema'; import { sentryLogger } from '@/lib/utils.server'; import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; +import { workerInstanceId } from '@/lib/kiloclaw/instance-registry'; const logInfo = sentryLogger('kiloclaw-instance-lifecycle', 'info'); const logError = sentryLogger('kiloclaw-instance-lifecycle', 'error'); @@ -39,20 +40,19 @@ export async function autoResumeIfSuspended( : and(eq(kiloclaw_instances.user_id, kiloUserId), isNull(kiloclaw_instances.destroyed_at)); const [targetInstance] = await db - .select({ id: kiloclaw_instances.id }) + .select({ id: kiloclaw_instances.id, sandbox_id: kiloclaw_instances.sandbox_id }) .from(kiloclaw_instances) .where(instanceFilter) .limit(1); - const targetInstanceId = targetInstance?.id; - if (targetInstanceId) { + if (targetInstance) { try { const client = new KiloClawInternalClient(); - await client.start(kiloUserId, targetInstanceId); + await client.start(kiloUserId, workerInstanceId(targetInstance)); } catch (startError) { logError('Failed to auto-resume instance', { user_id: kiloUserId, - instance_id: targetInstanceId, + instance_id: targetInstance.id, error: startError instanceof Error ? startError.message : String(startError), }); // Preserve suspension state so the interrupted-auto-resume retry @@ -97,6 +97,6 @@ export async function autoResumeIfSuspended( logInfo('Auto-resume completed', { user_id: kiloUserId, instance_id: instanceId ?? null, - had_active_instance: !!targetInstanceId, + had_active_instance: !!targetInstance, }); } diff --git a/src/lib/kiloclaw/kiloclaw-user-client.ts b/src/lib/kiloclaw/kiloclaw-user-client.ts index 2f7cdca3fa..137d33e410 100644 --- a/src/lib/kiloclaw/kiloclaw-user-client.ts +++ b/src/lib/kiloclaw/kiloclaw-user-client.ts @@ -9,11 +9,15 @@ import type { ChatCredentials, } from './types'; -type RequestContext = { userId: string }; +type RequestContext = { userId: string; instanceId?: string }; /** * KiloClaw worker client for user-facing routes. * Uses Bearer JWT auth (forwarding the user's token). Server-only. + * + * When `instanceId` is provided in RequestContext, it is appended as a + * query parameter so the worker resolves the correct DO (instance-keyed + * vs legacy userId-keyed). */ export class KiloClawUserClient { private authToken: string; @@ -28,7 +32,10 @@ export class KiloClawUserClient { } private async request(path: string, options?: RequestInit, ctx?: RequestContext): Promise { - const res = await fetch(`${this.baseUrl}${path}`, { + const url = ctx?.instanceId + ? `${this.baseUrl}${path}?instanceId=${encodeURIComponent(ctx.instanceId)}` + : `${this.baseUrl}${path}`; + const res = await fetch(url, { ...options, headers: { Authorization: `Bearer ${this.authToken}`, diff --git a/src/routers/admin-kiloclaw-instances-router.ts b/src/routers/admin-kiloclaw-instances-router.ts index 0c8dc75628..16258883c0 100644 --- a/src/routers/admin-kiloclaw-instances-router.ts +++ b/src/routers/admin-kiloclaw-instances-router.ts @@ -519,8 +519,9 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({ .mutation(async ({ input }) => { const fallbackMessage = 'Failed to start kilo CLI run'; try { + const instance = await getActiveInstance(input.userId); const client = new KiloClawInternalClient(); - return await client.startKiloCliRun(input.userId, input.prompt); + return await client.startKiloCliRun(input.userId, input.prompt, workerInstanceId(instance)); } catch (err) { console.error('Failed to start kilo CLI run for user:', input.userId, err); throwKiloclawAdminError(err, fallbackMessage); @@ -530,8 +531,9 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({ getKiloCliRunStatus: adminProcedure.input(GatewayProcessSchema).query(async ({ input }) => { const fallbackMessage = 'Failed to get kilo CLI run status'; try { + const instance = await getActiveInstance(input.userId); const client = new KiloClawInternalClient(); - return await client.getKiloCliRunStatus(input.userId); + return await client.getKiloCliRunStatus(input.userId, workerInstanceId(instance)); } catch (err) { console.error('Failed to get kilo CLI run status for user:', input.userId, err); throwKiloclawAdminError(err, fallbackMessage); @@ -751,7 +753,13 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({ ) .mutation(async ({ input }) => { const [row] = await db - .select({ user: kilocode_users }) + .select({ + user: kilocode_users, + instance: { + id: kiloclaw_instances.id, + sandbox_id: kiloclaw_instances.sandbox_id, + }, + }) .from(kiloclaw_instances) .innerJoin(kilocode_users, eq(kiloclaw_instances.user_id, kilocode_users.id)) .where(eq(kiloclaw_instances.id, input.instanceId)) @@ -767,7 +775,7 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({ try { return await client.restartMachine( input.imageTag ? { imageTag: input.imageTag } : undefined, - { userId: row.user.id } + { userId: row.user.id, instanceId: workerInstanceId(row.instance) } ); } catch (err) { console.error('Failed to restart machine for user:', row.user.id, err); diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 677e1a41ef..b291af68a8 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -922,17 +922,22 @@ export const kiloclawRouter = createTRPCRouter({ // User-facing (user client -- forwards user's short-lived JWT) getConfig: baseProcedure.query(async ({ ctx }) => { + const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawUserClient( generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }) ); - return client.getConfig({ userId: ctx.user.id }); + return client.getConfig({ userId: ctx.user.id, instanceId: workerInstanceId(instance) }); }), getChannelCatalog: baseProcedure.query(async ({ ctx }) => { + const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawUserClient( generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }) ); - const config = await client.getConfig({ userId: ctx.user.id }); + const config = await client.getConfig({ + userId: ctx.user.id, + instanceId: workerInstanceId(instance), + }); const channels = getEntriesByCategory('channel'); return channels.map(entry => ({ @@ -954,10 +959,14 @@ export const kiloclawRouter = createTRPCRouter({ }), getSecretCatalog: baseProcedure.query(async ({ ctx }) => { + const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawUserClient( generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }) ); - const config = await client.getConfig({ userId: ctx.user.id }); + const config = await client.getConfig({ + userId: ctx.user.id, + instanceId: workerInstanceId(instance), + }); const tools = getEntriesByCategory('tool'); return tools.map(entry => ({ @@ -994,12 +1003,13 @@ export const kiloclawRouter = createTRPCRouter({ .optional() ) .mutation(async ({ ctx, input }) => { + const instance = await getActiveInstance(ctx.user.id); const client = new KiloClawUserClient( generateApiToken(ctx.user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }) ); const result = await client.restartMachine( input?.imageTag ? { imageTag: input.imageTag } : undefined, - { userId: ctx.user.id } + { userId: ctx.user.id, instanceId: workerInstanceId(instance) } ); if (result.success) { PostHogClient().capture({ From c6e8a7635567234c7ef6c8b2be8754eaafa9032b Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 31 Mar 2026 21:24:38 -0600 Subject: [PATCH 05/25] feat(admin): bulk KiloClaw trial extend/resurrect (#1751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding bulk trial extension dash * fix: resolve merge conflict, keep both kiloclaw audit actions * feat(admin): implement bulk KiloClaw trial extend/resurrect - extendTrials takes emails, resolves to userIds + subscriptions in two batch queries; skips users with no subscription or active paid plan - canceled subscriptions are fully reset to trialing (mirrors single-user admin reset path including email log clear) - matchUsers now joins kiloclaw_subscriptions and returns subscriptionStatus so the UI can show eligibility before the extend step - no DB transactions; writes are independent per user with try/catch isolation - removes kiloclaw_trial_grants dependency; no-subscription users are skipped * fix(admin): remove dead kiloclaw_trial_grants infra, add TOCTOU guards - Remove kiloclaw_trial_grants table from schema, migration, journal, and snapshot — the bulk extend tool skips no-subscription users so the table would never have rows; revert kiloclaw-router.ts to the standard trial duration and remove the GDPR handling from user.ts - Delete empty src/app/admin/contributors/page.tsx (would break Next.js build) - Add WHERE status = 'trialing'/'canceled' guards to both UPDATE calls in extendTrials so a subscription that changes state between the match read and the write is detected and returned as an error rather than silently modified - Fix stale docstring that still mentioned best-effort instance start - Disable Apply button when no eligible (trialing/canceled) users remain * fix(admin): restore journal.json from main, fix ineligible count and button label - Restore _journal.json to origin/main state (no migration was added by this PR; previous commits had introduced a trailing comma making it invalid JSON) - ineligibleCount now covers all users who will be skipped (active paid plans AND no-subscription), not just active paid plans - Apply button now shows eligibleCount (trialing + canceled users) instead of total matchedUsers.length so admins see exactly how many will be processed * fix(admin): reset selectedColumn on new file load, guard GREATEST against NULL trial end - Always reset selectedColumn when a new file is loaded so a second upload without Clear doesn't leave a stale column name from the previous file causing 0 emails to be detected - Wrap trial_ends_at in COALESCE before GREATEST so a NULL trial end date (invalid but not DB-constrained) extends from now() rather than producing NULL and overwriting the value with NULL * style: apply oxfmt formatting * fix(admin): make audit log and email log writes best-effort after subscription commit After the subscription UPDATE succeeds, a failure in the subsequent email-log delete or audit-log insert was propagating through the outer catch and reporting success:false to the admin. This caused retry attempts to double-apply the extension (retry hits the trialing path instead of the canceled/resurrect path) and left no audit trail for the first apply. Wrap both secondary writes in their own try/catch so a failure there does not mask the already-committed subscription state change. * refactor(admin): move bulk extend trial into KiloClaw subtab, add paste input Move the standalone /admin/extend-claw-trial page into the /admin/kiloclaw dashboard as a "Bulk Extend" tab. Add a paste-based email input mode (default) alongside the existing CSV upload. Remove the separate sidebar nav entry. * Changes made: 1. src/app/admin/bulk-credits/page.tsx — Converted from a standalone flat page to a tabbed layout with two tabs: - "Bulk Credits" — the existing bulk credit granting functionality (extracted into BulkCreditsTab component) - "KiloClaw Trial Extension" — the KiloclawExtendTrial component imported from its existing location - Page title updated to "Bulk Credits & Trials", breadcrumb updated to match - Tab state synced to URL via ?tab=trial-extension query param (default/no param = bulk-credits tab) 2. src/app/admin/components/KiloclawDashboard.tsx — Removed the "Bulk Extend" tab and its KiloclawExtendTrial import/usage 3. src/app/admin/components/AppSidebar.tsx — Updated sidebar nav item title from "Bulk Credits" to "Bulk Credits & Trials" * fix(admin): use parameterized interval, query for matchUsers, filter ineligible before extend * fix(admin): scope subscription ops to most recent row, clear payment_source and pending_conversion on resurrection * fix(admin): use selectDistinctOn for latest-sub query, revert conservative payment_source/pending_conversion clear * fix(admin): use desc() for created_at ordering in selectDistinctOn * feat(admin): add CSV upload to paste mode, export ineligible, include user_id/instance_id in all exports * style: remove unused types, apply oxfmt formatting * refactor(admin): unify paste/CSV input into single layout with tab-switched input widget * feat(admin): cap trial extensions at 1 year from now, add tests for 500-day input * fix(admin): revert zod max to 365, test ceiling via existing trial remainder * feat(admin): include stripe_subscription_id in ineligible export, reason in table and csv * feat(admin): add trial_ends_at to match table, instance_id to success export, fix csv encoding, clean up columns * refactor(admin): split matched users into eligible/ineligible/unmatched tables, move exports inline * style(admin): remove redundant Reason column from ineligible table * fix(admin): tab switch respects active input mode without clearing other tab state * Delete PLAN.md * fix(admin): treat trial-at-limit users as ineligible at match time, remove LEAST from sql, remove Days column * fix: remove duplicate audit action literal, add user_id+instance_id to failed export * fix(admin): invalidate matchUsers cache after extendTrials so at_limit reflects new trial end * fix(admin): restore LEAST ceiling clamp in extendTrials, remove orphaned Days header * test(admin): add ceiling test for 200-day remaining + 365-day extension * Remove trialDays from the output schema/path * Undo random agent change * refactor(admin): clean up KiloclawExtendTrial and fix at_limit boundary - Extract isEligible() predicate to replace 4 inline copies - Move extractedEmails computation before handleMatchUsers so handler reuses it - Extract shared downloadCsv to src/lib/admin-csv.ts (used by both bulk-credits and KiloclawExtendTrial) - Move trialDays input from Step 1 to Step 2 alongside the Apply button - Fix at_limit check: change > to >= so users exactly at the 1-year ceiling are blocked (extending would be a no-op) - Add boundary test: trial_ends_at exactly at 365 days must produce at_limit - Add comments explaining raw button tabs (avoids nested Radix Tabs) and useRef toast dedup (React Query v5 workaround) * style: apply oxfmt formatting * fix(admin): address review findings on bulk extend trial - Add csvField() RFC 4180 escaping helper to admin-csv.ts and apply it in all three export functions in KiloclawExtendTrial so error messages with commas/quotes can't corrupt downloaded CSVs - Parallelize DB updates in extendTrials via Promise.allSettled, extracting per-user logic into processOneEmail; eliminates up to 1000 sequential round-trips for large batches - Align at_limit ceiling math with SQL interval '1 year': use calendar-year setFullYear arithmetic instead of fixed 365-day ms offset - Compare at UTC-day granularity in the beyondCeiling check so ms-level clock drift cannot let an exact-ceiling row slip through as eligible - Extract parseCsvToTable, extractEmailsFromColumn, guessEmailColumn, and parseEmailList from KiloclawExtendTrial into admin-csv.ts; remove the redundant CsvData type alias (use CsvTableData directly) * fix(admin): address review findings on bulk extend trial - Replace at_limit UTC-midnight truncation with exact calendar-year ms comparison so same-day-but-earlier trials are not over-blocked - Update test fixture to use calendar-year arithmetic (setFullYear) to match the implementation instead of 365 * MS_PER_DAY - Invalidate matchUsers query cache in handleMatchUsers so re-submitting the same email list always issues a fresh network request - Replace parseInt with Number + Number.isInteger for trialDays to reject decimal inputs instead of silently truncating them; add step=1 to the input element for browser-side constraint - Export ExtendTrialResult from router; import it in KiloclawExtendTrial to eliminate the duplicate type definition - Wrap parseEmailList and extractedEmails in useMemo - Add browser-only JSDoc to downloadCsv * style: apply formatter to review fix changes * fix(admin): harden test boundary checks against leap years and ms drift - Replace 365 * MS_PER_DAY with calendar-year setFullYear arithmetic in extendTrials assertion bounds so tests are correct on leap-year boundaries - Change 'exactly at ceiling' at_limit test to store 1 year + 1 day so the few-ms gap between test setup and matchUsers execution cannot let the row appear under the boundary; remove now-unused MS_PER_YEAR --------- Co-authored-by: Brian Turcotte --- packages/db/src/schema-types.ts | 1 + src/app/admin/bulk-credits/page.tsx | 624 +++++++------- src/app/admin/components/AppSidebar.tsx | 2 +- .../admin/components/KiloclawExtendTrial.tsx | 781 ++++++++++++++++++ src/lib/admin-csv.ts | 135 +++ src/routers/admin-router.ts | 2 + .../admin/extend-claw-trial-router.test.ts | 182 ++++ src/routers/admin/extend-claw-trial-router.ts | 414 ++++++++++ 8 files changed, 1849 insertions(+), 292 deletions(-) create mode 100644 src/app/admin/components/KiloclawExtendTrial.tsx create mode 100644 src/lib/admin-csv.ts create mode 100644 src/routers/admin/extend-claw-trial-router.test.ts create mode 100644 src/routers/admin/extend-claw-trial-router.ts diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 778c49f54f..65988a61da 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -171,6 +171,7 @@ export const KiloClawAdminAuditAction = z.enum([ 'kiloclaw.config.restore', 'kiloclaw.doctor.run', 'kiloclaw.machine.destroy_fly', + 'kiloclaw.subscription.bulk_trial_grant', ]); export type KiloClawAdminAuditAction = z.infer; diff --git a/src/app/admin/bulk-credits/page.tsx b/src/app/admin/bulk-credits/page.tsx index c30e1aad5a..759844b002 100644 --- a/src/app/admin/bulk-credits/page.tsx +++ b/src/app/admin/bulk-credits/page.tsx @@ -2,12 +2,14 @@ import { useState, useCallback } from 'react'; import Link from 'next/link'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useMutation } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, @@ -30,6 +32,8 @@ import { } from 'lucide-react'; import AdminPage from '@/app/admin/components/AdminPage'; import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; +import { KiloclawExtendTrial } from '@/app/admin/components/KiloclawExtendTrial'; +import { downloadCsv } from '@/lib/admin-csv'; type MatchedUser = { email: string; @@ -86,18 +90,6 @@ function parseCsvEmails(text: string): { emails: string[]; skippedLines: string[ return { emails, skippedLines }; } -function downloadCsv(content: string, filename: string) { - const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); -} - function generateEmailsCsv(emails: string[]): string { return 'email\n' + emails.join('\n'); } @@ -105,12 +97,20 @@ function generateEmailsCsv(emails: string[]): string { const breadcrumbs = ( <> - Bulk Credits + Bulk Credits & Trials ); -export default function BulkCreditsPage() { +const tabTriggerClass = + 'text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:text-foreground rounded-none border-b-2 border-transparent px-0 py-3 text-sm font-medium transition-colors data-[state=active]:border-0 data-[state=active]:border-b-2 data-[state=active]:bg-transparent data-[state=active]:shadow-none'; + +const VALID_TABS = ['bulk-credits', 'trial-extension'] as const; +type Tab = (typeof VALID_TABS)[number]; +const isValidTab = (value: string | null): value is Tab => + value !== null && (VALID_TABS as readonly string[]).includes(value); + +function BulkCreditsTab() { const trpc = useTRPC(); // CSV upload state @@ -276,317 +276,359 @@ export default function BulkCreditsPage() { const isFormValid = matchedUsers.length > 0 && parseFloat(amountUsd) > 0; return ( - -
-
-

Bulk User Credits

-

- Import a CSV of email addresses to grant credits to multiple personal Kilo accounts at - once. -

-
+
+
+

+ Import a CSV of email addresses to grant credits to multiple personal Kilo accounts at + once. +

+
- {/* CSV Upload Section */} + {/* CSV Upload Section */} + + + + + CSV Import + + + Upload a CSV file with email addresses. The file should have one email per line or a + column containing emails. + + + + {/* Drop zone */} +
+ +

Drop CSV file here or click to browse

+ +
+ + {/* Skipped lines warning */} + {skippedLines.length > 0 && ( +
+

+ + Skipped lines ({skippedLines.length}) +

+
    + {skippedLines.slice(0, 5).map((line, i) => ( +
  • {line}
  • + ))} + {skippedLines.length > 5 &&
  • ...and {skippedLines.length - 5} more
  • } +
+
+ )} + + {/* Parsed emails preview */} + {parsedEmails.length > 0 && !hasMatched && ( +
+

+ Found {parsedEmails.length} email{parsedEmails.length !== 1 ? 's' : ''} in CSV +

+
+
    + {parsedEmails.slice(0, 10).map((email, i) => ( +
  • + {email} +
  • + ))} + {parsedEmails.length > 10 && ( +
  • + ...and {parsedEmails.length - 10} more +
  • + )} +
+
+
+ + +
+
+ )} +
+
+ + {/* Matched Users Section */} + {hasMatched && !creditResults && ( - - CSV Import + + Matched Users ({matchedUsers.length}) - Upload a CSV file with email addresses. The file should have one email per line or a - column containing emails. + {unmatchedEmails.length > 0 + ? `${unmatchedEmails.length} email${unmatchedEmails.length !== 1 ? 's' : ''} could not be matched to existing accounts.` + : 'All emails matched to existing Kilo accounts.'} - {/* Drop zone */} -
- -

Drop CSV file here or click to browse

- -
- - {/* Skipped lines warning */} - {skippedLines.length > 0 && ( + {/* Unmatched emails warning */} + {unmatchedEmails.length > 0 && (
-

- - Skipped lines ({skippedLines.length}) -

+
+

+ + Unmatched emails ({unmatchedEmails.length}) +

+ +
    - {skippedLines.slice(0, 5).map((line, i) => ( -
  • {line}
  • + {unmatchedEmails.slice(0, 5).map((item, i) => ( +
  • + {item.email} +
  • ))} - {skippedLines.length > 5 &&
  • ...and {skippedLines.length - 5} more
  • } + {unmatchedEmails.length > 5 &&
  • ...and {unmatchedEmails.length - 5} more
  • }
)} - {/* Parsed emails preview */} - {parsedEmails.length > 0 && !hasMatched && ( -
-

- Found {parsedEmails.length} email{parsedEmails.length !== 1 ? 's' : ''} in CSV -

-
-
    - {parsedEmails.slice(0, 10).map((email, i) => ( -
  • - {email} -
  • - ))} - {parsedEmails.length > 10 && ( -
  • - ...and {parsedEmails.length - 10} more -
  • - )} -
-
-
- - -
-
- )} -
-
- - {/* Matched Users Section */} - {hasMatched && !creditResults && ( - - - - - Matched Users ({matchedUsers.length}) - - - {unmatchedEmails.length > 0 - ? `${unmatchedEmails.length} email${unmatchedEmails.length !== 1 ? 's' : ''} could not be matched to existing accounts.` - : 'All emails matched to existing Kilo accounts.'} - - - - {/* Unmatched emails warning */} - {unmatchedEmails.length > 0 && ( -
-
-

- - Unmatched emails ({unmatchedEmails.length}) -

- -
-
    - {unmatchedEmails.slice(0, 5).map((item, i) => ( -
  • - {item.email} -
  • - ))} - {unmatchedEmails.length > 5 && ( -
  • ...and {unmatchedEmails.length - 5} more
  • - )} -
-
- )} - - {/* Matched users table */} - {matchedUsers.length > 0 && ( -
- - - - Email - Name - User ID - - - - {matchedUsers.map((user, i) => ( - - {user.email} - {user.userName || '—'} - - - {user.userId.slice(0, 8)}... - - - - ))} - -
-
- )} - - {/* Credit allocation form */} - {matchedUsers.length > 0 && ( -
-

- - Credit Allocation -

-
-
- - setAmountUsd(e.target.value)} - min="0.01" - step="0.01" - /> -
-
- - setExpirationDate(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - /> -
-
-
- - -
-
- )} -
-
- )} - - {/* Results Section */} - {creditResults && ( - - - - - Credit Results - - - Successfully added credits to {creditResults.filter(r => r.success).length} of{' '} - {creditResults.length} accounts. - - - - {/* Export buttons */} -
- - {creditResults.some(r => !r.success) && ( - - )} -
- - {/* Results table */} -
+ {/* Matched users table */} + {matchedUsers.length > 0 && ( +
- Status Email + Name User ID - Error - {creditResults.map((result, i) => ( + {matchedUsers.map((user, i) => ( + {user.email} + {user.userName || '—'} - {result.success ? ( - - ) : ( - - )} - - {result.email} - - {result.userId ? ( - - {result.userId.slice(0, 8)}... - - ) : ( - - )} - - - {result.error || ''} + + {user.userId.slice(0, 8)}... + ))}
+ )} - + +
+
+ )} + + + )} + + {/* Results Section */} + {creditResults && ( + + + + + Credit Results + + + Successfully added credits to {creditResults.filter(r => r.success).length} of{' '} + {creditResults.length} accounts. + + + + {/* Export buttons */} +
+ - - - )} + {creditResults.some(r => !r.success) && ( + + )} +
+ + {/* Results table */} +
+ + + + Status + Email + User ID + Error + + + + {creditResults.map((result, i) => ( + + + {result.success ? ( + + ) : ( + + )} + + {result.email} + + {result.userId ? ( + + {result.userId.slice(0, 8)}... + + ) : ( + + )} + + + {result.error || ''} + + + ))} + +
+
+ + +
+
+ )} +
+ ); +} + +export default function BulkCreditsPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const tabParam = searchParams.get('tab'); + const activeTab: Tab = isValidTab(tabParam) ? tabParam : 'bulk-credits'; + + const onTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'bulk-credits') { + params.delete('tab'); + } else { + params.set('tab', value); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); + }; + + return ( + +
+
+

Bulk Credits & Trials

+
+ + + + + Bulk Credits + + + KiloClaw Trial Extension + + + + + + + + +
); diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 01e36ddecb..20a82899a2 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -88,7 +88,7 @@ const financialItems: MenuItem[] = [ icon: () => , }, { - title: () => 'Bulk Credits', + title: () => 'Bulk Credits & Trials', url: '/admin/bulk-credits', icon: () => , }, diff --git a/src/app/admin/components/KiloclawExtendTrial.tsx b/src/app/admin/components/KiloclawExtendTrial.tsx new file mode 100644 index 0000000000..49867b3825 --- /dev/null +++ b/src/app/admin/components/KiloclawExtendTrial.tsx @@ -0,0 +1,781 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { + Upload, + FileSpreadsheet, + CheckCircle2, + XCircle, + AlertCircle, + Clock, + Play, + RotateCcw, + Download, +} from 'lucide-react'; +import { + downloadCsv, + csvField, + parseCsvToTable, + extractEmailsFromColumn, + guessEmailColumn, + parseEmailList, + type CsvTableData, +} from '@/lib/admin-csv'; +import type { ExtendTrialResult as TrialResult } from '@/routers/admin/extend-claw-trial-router'; + +type InputMode = 'paste' | 'csv'; + +function ineligibleReason(status: string | null): string { + if (status === null) return 'No subscription - must provision first'; + if (status === 'active') return 'Active paid subscription'; + if (status === 'past_due') return 'Past due - active paid subscription'; + if (status === 'unpaid') return 'Unpaid - active paid subscription'; + if (status === 'at_limit') return 'Trial already extends beyond 1 year'; + return `Ineligible status: ${status}`; +} + +function subscriptionStatusBadge(status: string | null) { + if (status === null) return no subscription; + if (status === 'trialing') return trialing; + if (status === 'canceled') return canceled; + if (status === 'at_limit') return at limit; + return ( + + {status} + + ); +} + +// --- Component --- + +const ACTION_CONFIG = { + extended: { label: 'Extended', icon: Clock, variant: 'default' as const }, + restarted: { label: 'Restarted', icon: RotateCcw, variant: 'secondary' as const }, +}; + +function isEligible(u: { subscriptionStatus: string | null }): boolean { + return u.subscriptionStatus === 'trialing' || u.subscriptionStatus === 'canceled'; +} + +export function KiloclawExtendTrial() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + // Input mode + const [inputMode, setInputMode] = useState('paste'); + const [pastedText, setPastedText] = useState(''); + + // Step 1: CSV state + const [csvData, setCsvData] = useState(null); + const [selectedColumn, setSelectedColumn] = useState(''); + const [trialDays, setTrialDays] = useState('7'); + const [isDragging, setIsDragging] = useState(false); + + // Step 2: Match state — null means "not yet submitted"; non-null triggers the query + const [emailsToMatch, setEmailsToMatch] = useState(null); + + // Step 3: Results + const [results, setResults] = useState(null); + + // Query — only fires once emailsToMatch is a non-empty array + const matchUsersQuery = useQuery({ + ...trpc.admin.extendClawTrial.matchUsers.queryOptions({ + emails: emailsToMatch ?? [], + }), + enabled: emailsToMatch !== null && emailsToMatch.length > 0, + }); + + const matchedUsers = matchUsersQuery.data?.matched ?? []; + const unmatchedEmails = matchUsersQuery.data?.unmatched ?? []; + const hasMatched = matchUsersQuery.isSuccess && emailsToMatch !== null; + + // Toast on match completion — fire once per successful fetch, not on every render. + // useQuery v5 removed onSuccess from queryOptions, so we track the previous data + // value in a ref and only toast when it changes to a new non-null result. + const prevMatchDataRef = useRef(matchUsersQuery.data); + useEffect(() => { + if (matchUsersQuery.data === prevMatchDataRef.current) return; + prevMatchDataRef.current = matchUsersQuery.data; + if (!matchUsersQuery.data) return; + const { matched, unmatched } = matchUsersQuery.data; + if (unmatched.length === 0) { + toast.success(`All ${matched.length} emails matched to users`); + } else { + toast.warning(`Matched ${matched.length} users, ${unmatched.length} emails not found`); + } + }, [matchUsersQuery.data]); + + useEffect(() => { + if (matchUsersQuery.error) { + toast.error( + matchUsersQuery.error instanceof Error + ? matchUsersQuery.error.message + : 'Failed to match users' + ); + } + }, [matchUsersQuery.error]); + + const extendTrialsMutation = useMutation( + trpc.admin.extendClawTrial.extendTrials.mutationOptions({ + onSuccess: trialResults => { + setResults(trialResults); + const successCount = trialResults.filter(r => r.success).length; + const failCount = trialResults.length - successCount; + if (failCount === 0) { + toast.success(`Successfully processed ${successCount} users`); + } else { + toast.warning(`Processed ${successCount} users, ${failCount} failed`); + } + // Refetch matched users so at_limit status reflects the new trial_ends_at values. + void queryClient.invalidateQueries( + trpc.admin.extendClawTrial.matchUsers.queryOptions({ emails: emailsToMatch ?? [] }) + ); + }, + onError: error => { + toast.error(error.message || 'Failed to extend trials'); + }, + }) + ); + + // File handling + const handleFile = useCallback((file: File) => { + setResults(null); + setEmailsToMatch(null); + const reader = new FileReader(); + reader.onload = e => { + const text = typeof e.target?.result === 'string' ? e.target.result : ''; + const data = parseCsvToTable(text); + setCsvData(data); + const guessed = guessEmailColumn(data.headers, data.rows); + setSelectedColumn(guessed ?? ''); + }; + reader.readAsText(file); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }, + [handleFile] + ); + + const extractedEmails = useMemo( + () => (csvData && selectedColumn ? extractEmailsFromColumn(csvData.rows, selectedColumn) : []), + [csvData, selectedColumn] + ); + const csvEmailCount = extractedEmails.length; + + // Actions + const handleMatchUsers = () => { + const emails = + inputMode === 'csv' && csvData && selectedColumn + ? extractedEmails + : parseEmailList(pastedText); + + if (emails.length === 0) { + toast.error('No valid emails found'); + return; + } + if (emails.length > 1000) { + toast.error(`Too many emails (${emails.length}). Maximum batch size is 1,000.`); + return; + } + setResults(null); + // Invalidate any cached result before updating state so React Query always + // issues a fresh network request — even when the email list hasn't changed + // (e.g. re-match after a "status changed since match" failure). + void queryClient.invalidateQueries( + trpc.admin.extendClawTrial.matchUsers.queryOptions({ emails }) + ); + setEmailsToMatch(emails); + }; + + const handleExtendTrials = () => { + const eligibleEmails = matchedUsers.filter(isEligible).map(u => u.email); + if (eligibleEmails.length === 0) return; + const days = Number(trialDays); + if (!Number.isInteger(days) || days <= 0) { + toast.error('Please enter a whole number of days'); + return; + } + extendTrialsMutation.mutate({ + emails: eligibleEmails, + trialDays: days, + }); + }; + + const handleClear = () => { + setCsvData(null); + setSelectedColumn(''); + setTrialDays('7'); + setPastedText(''); + setEmailsToMatch(null); + setResults(null); + setInputMode('paste'); + }; + + const handleDownloadResults = (success: boolean) => { + if (!results) return; + const filtered = results.filter(r => r.success === success); + if (filtered.length === 0) { + toast.info(`No ${success ? 'successful' : 'failed'} results to export`); + return; + } + const content = success + ? 'email,instance_id,action,new_trial_ends_at\n' + + filtered + .map(r => + [r.email, r.instanceId ?? '', r.action ?? '', r.newTrialEndsAt ?? ''] + .map(csvField) + .join(',') + ) + .join('\n') + : 'email,user_id,instance_id,error\n' + + filtered + .map(r => [r.email, r.userId, r.instanceId ?? '', r.error ?? ''].map(csvField).join(',')) + .join('\n'); + downloadCsv(content, `${success ? 'successful' : 'failed'}-trial-extensions.csv`); + }; + + const handleDownloadIneligible = () => { + const ineligible = matchedUsers.filter( + u => u.subscriptionStatus !== 'trialing' && u.subscriptionStatus !== 'canceled' + ); + if (ineligible.length === 0) { + toast.info('No ineligible users to export'); + return; + } + const content = + 'email,instance_id,stripe_subscription_id,reason\n' + + ineligible + .map(u => + [ + u.email, + u.instanceId ?? '', + u.stripeSubscriptionId ?? '', + ineligibleReason(u.subscriptionStatus), + ] + .map(csvField) + .join(',') + ) + .join('\n'); + downloadCsv(content, 'ineligible-users.csv'); + }; + + const handleDownloadUnmatched = () => { + if (unmatchedEmails.length === 0) return; + const content = 'email\n' + unmatchedEmails.map(u => u.email).join('\n'); + downloadCsv(content, 'unmatched-emails.csv'); + }; + + // Computed + const pastedEmails = useMemo(() => parseEmailList(pastedText), [pastedText]); + const pastedEmailCount = pastedEmails.length; + + const currentEmailCount = inputMode === 'csv' ? csvEmailCount : pastedEmailCount; + + const eligibleCount = matchedUsers.filter(isEligible).length; + + const ineligibleCount = matchedUsers.length - eligibleCount; + + const canMatch = + inputMode === 'csv' + ? selectedColumn && csvEmailCount > 0 && csvEmailCount <= 1000 + : pastedEmailCount > 0 && pastedEmailCount <= 1000; + + return ( +
+
+

+ Paste a list of email addresses or upload a CSV to extend or restart KiloClaw trials in + bulk. Users with active paid subscriptions are skipped automatically. +

+
+ + {/* Step 1: Email Input + Configuration */} + + + + + {results ? 'Start New Import' : 'Email Input'} + + {/* Plain + +
+ + + {/* Input widget — the only thing that changes between tabs */} + {inputMode === 'paste' ? ( +
+ +