From 0c7fdda1244187e9c78fcdf28befe301ae019093 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 13:34:09 +0100 Subject: [PATCH 1/7] Add inactive user re-engagement rollout script and promo category Register 'cli-v1-rollout' promo credit category ($1, 7-day expiry, idempotent) and add a script to grant credits to lapsed users who haven't used Kilo in 30+ days. --- src/lib/promoCreditCategories.ts | 7 + src/scripts/d2025-02-04_cli-v1-rollout.ts | 250 ++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/scripts/d2025-02-04_cli-v1-rollout.ts diff --git a/src/lib/promoCreditCategories.ts b/src/lib/promoCreditCategories.ts index 86b7a5bab..2936fcdf8 100644 --- a/src/lib/promoCreditCategories.ts +++ b/src/lib/promoCreditCategories.ts @@ -177,6 +177,13 @@ const nonSelfServicePromos: readonly NonSelfServicePromoCreditCategoryConfig[] = is_idempotent: true, expiry_hours: 30 * 24, }, + { + credit_category: 'cli-v1-rollout', + description: 'CLI V1 Rollout - $1 credit with 7 day expiry', + amount_usd: 1, + is_idempotent: true, + expiry_hours: 7 * 24, + }, { credit_category: 'payment-tripled', diff --git a/src/scripts/d2025-02-04_cli-v1-rollout.ts b/src/scripts/d2025-02-04_cli-v1-rollout.ts new file mode 100644 index 000000000..33c31dee0 --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout.ts @@ -0,0 +1,250 @@ +import { db } from '@/lib/drizzle'; +import { kilocode_users, microdollar_usage, type User } from '@/db/schema'; +import { isNull, gt, and, gte } from 'drizzle-orm'; +import { grantCreditForCategory } from '@/lib/promotionalCredits'; +import pLimit from 'p-limit'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +const isDryRun = !process.argv.includes('--apply'); + +// Resume support: If script crashes, you can resume from the last processed user ID +// Example: pnpm script src/scripts/d2025-02-04_cli-v1-rollout.ts --resume=user-abc-123 +const resumeFromArg = process.argv.find(arg => arg.startsWith('--resume=')); +const RESUME_FROM_USER_ID = resumeFromArg ? resumeFromArg.split('=')[1] : null; + +const BATCH_SIZE = 10; +const SLEEP_AFTER_BATCH_MS = 1000; +const CONCURRENT = 10; +const FETCH_BATCH_SIZE = 1000; +const INACTIVE_DAYS = 30; + +type ProcessingStats = { + processed: number; + successful: number; + skipped: number; + skippedActive: number; + skippedNeverUsed: number; + failed: number; +}; + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function run() { + console.log('Starting CLI V1 rollout credit distribution...'); + console.log('Credit: $1 with 7-day expiry for inactive users (no usage in last 30 days)\n'); + + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made to the database'); + console.log('Run with --apply flag to actually grant credits\n'); + } + + const scriptStartTime = Date.now(); + + // Step 1: Pre-compute recently active users (last 30 days) + console.log(`Fetching recently active user IDs (last ${INACTIVE_DAYS} days)...`); + const cutoff = new Date(Date.now() - INACTIVE_DAYS * 24 * 60 * 60 * 1000); + + const recentlyActiveRows = await db + .selectDistinct({ userId: microdollar_usage.kilo_user_id }) + .from(microdollar_usage) + .where(gte(microdollar_usage.created_at, cutoff.toISOString())); + + const recentlyActiveSet = new Set(recentlyActiveRows.map(r => r.userId)); + console.log(`Found ${recentlyActiveSet.size} recently active users to EXCLUDE`); + + // Step 2: Pre-compute users who have ever used Kilo + console.log('Fetching all users who have ever used Kilo...'); + const everUsedRows = await db + .selectDistinct({ userId: microdollar_usage.kilo_user_id }) + .from(microdollar_usage); + + const everUsedSet = new Set(everUsedRows.map(r => r.userId)); + console.log(`Found ${everUsedSet.size} users who have ever used Kilo`); + + // Eligible = ever used - recently active + const eligibleCount = [...everUsedSet].filter(id => !recentlyActiveSet.has(id)).length; + console.log(`Estimated eligible inactive users: ${eligibleCount}\n`); + + const stats: ProcessingStats = { + processed: 0, + successful: 0, + skipped: 0, + skippedActive: 0, + skippedNeverUsed: 0, + failed: 0, + }; + + const failedUserIds: string[] = []; + const limit = pLimit(CONCURRENT); + let globalBatchNumber = 0; + let lastUserId: string | null = RESUME_FROM_USER_ID; + let hasMore = true; + + if (RESUME_FROM_USER_ID) { + console.log(`RESUMING from user ID: ${RESUME_FROM_USER_ID}\n`); + } + + console.log('Starting cursor-based pagination...\n'); + + // Step 3: Cursor-paginated loop over non-blocked users + while (hasMore) { + const users: User[] = await db.query.kilocode_users.findMany({ + where: lastUserId + ? and(isNull(kilocode_users.blocked_reason), gt(kilocode_users.id, lastUserId)) + : isNull(kilocode_users.blocked_reason), + orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], + limit: FETCH_BATCH_SIZE, + }); + + if (users.length === 0) { + hasMore = false; + break; + } + + lastUserId = users[users.length - 1].id; + hasMore = users.length === FETCH_BATCH_SIZE; + + console.log(`\nFetched ${users.length} users from database`); + + for (let i = 0; i < users.length; i += BATCH_SIZE) { + const batch = users.slice(i, Math.min(i + BATCH_SIZE, users.length)); + globalBatchNumber++; + + console.log( + `\nProcessing batch ${globalBatchNumber} (${batch.length} users, total processed: ${stats.processed})...` + ); + + const batchPromises = batch.map(user => + limit(async () => { + stats.processed++; + + // Skip users who never used Kilo + if (!everUsedSet.has(user.id)) { + stats.skippedNeverUsed++; + return; + } + + // Skip users who are recently active + if (recentlyActiveSet.has(user.id)) { + stats.skippedActive++; + return; + } + + try { + if (isDryRun) { + stats.successful++; + if (stats.successful <= 100) { + console.log( + ` [DRY RUN] Would grant to: ${user.id} (${user.google_user_email})` + ); + } + return; + } + + const result = await grantCreditForCategory(user, { + credit_category: 'cli-v1-rollout', + counts_as_selfservice: false, + }); + + if (!result.success) { + const alreadyApplied = result.message.includes('already been applied'); + + if (alreadyApplied) { + stats.skipped++; + if (stats.skipped <= 100) { + console.log( + ` Skipped ${user.id} (${user.google_user_email}): Already applied` + ); + } + } else { + stats.failed++; + failedUserIds.push(user.id); + console.log( + ` Failed for ${user.id} (${user.google_user_email}): ${result.message}` + ); + } + } else { + stats.successful++; + if (stats.successful <= 100 || stats.successful % 1000 === 0) { + console.log( + ` Granted to: ${user.id} (${user.google_user_email}) [#${stats.successful}]` + ); + } + } + } catch (error) { + stats.failed++; + const errorMessage = error instanceof Error ? error.message : String(error); + failedUserIds.push(user.id); + console.error( + ` Error processing ${user.id} (${user.google_user_email}):`, + errorMessage + ); + } + }) + ); + + await Promise.all(batchPromises); + + const elapsedSeconds = (Date.now() - scriptStartTime) / 1000; + const usersPerSecond = stats.processed / elapsedSeconds; + + console.log(`\nProgress: ${stats.processed} users processed`); + console.log(` Successful: ${stats.successful}`); + console.log(` Skipped (already applied): ${stats.skipped}`); + console.log(` Skipped (recently active): ${stats.skippedActive}`); + console.log(` Skipped (never used Kilo): ${stats.skippedNeverUsed}`); + console.log(` Failed: ${stats.failed}`); + console.log(` Rate: ${usersPerSecond.toFixed(2)} users/sec`); + console.log(` Last user ID: ${lastUserId}`); + console.log(` To resume from this point: --resume=${lastUserId}`); + + if (hasMore || i + BATCH_SIZE < users.length) { + await sleep(SLEEP_AFTER_BATCH_MS); + } + } + } + + // Final report + const totalElapsedSeconds = (Date.now() - scriptStartTime) / 1000; + console.log('\n' + '='.repeat(60)); + console.log('Rollout completed!'); + console.log('='.repeat(60)); + console.log(`\nFinal Statistics:`); + console.log(` Total processed: ${stats.processed}`); + console.log(` Successful: ${stats.successful}`); + console.log(` Skipped (already applied): ${stats.skipped}`); + console.log(` Skipped (recently active): ${stats.skippedActive}`); + console.log(` Skipped (never used Kilo): ${stats.skippedNeverUsed}`); + console.log(` Failed: ${stats.failed}`); + console.log(` Total time: ${totalElapsedSeconds.toFixed(1)}s`); + console.log(` Average rate: ${(stats.processed / totalElapsedSeconds).toFixed(2)} users/sec`); + + if (isDryRun) { + console.log('\nThis was a DRY RUN. No actual changes were made.'); + console.log('To apply changes, run with --apply flag'); + } else { + console.log(`\nTotal credits granted: $${stats.successful.toFixed(2)}`); + } + + if (failedUserIds.length > 0) { + const logFileName = `failed-users-cli-v1-rollout-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const logFilePath = path.join(process.cwd(), logFileName); + const logContent = failedUserIds.join('\n') + '\n'; + + await fs.writeFile(logFilePath, logContent, 'utf-8'); + console.log(`\n${failedUserIds.length} failed user IDs written to: ${logFileName}`); + } +} + +run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); From 6b0da99e78a0f3b98e7dc1d754affd8b33ebfd1d Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 13:49:27 +0100 Subject: [PATCH 2/7] Remove --resume mechanism, rely on idempotency for re-runs The idempotent credit grant (onConflictDoNothing) already handles re-runs safely, making the resume cursor unnecessary complexity. Also fix misleading dollar amount in final stats log. --- src/scripts/d2025-02-04_cli-v1-rollout.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/scripts/d2025-02-04_cli-v1-rollout.ts b/src/scripts/d2025-02-04_cli-v1-rollout.ts index 33c31dee0..aac5aecb0 100644 --- a/src/scripts/d2025-02-04_cli-v1-rollout.ts +++ b/src/scripts/d2025-02-04_cli-v1-rollout.ts @@ -8,11 +8,6 @@ import * as path from 'node:path'; const isDryRun = !process.argv.includes('--apply'); -// Resume support: If script crashes, you can resume from the last processed user ID -// Example: pnpm script src/scripts/d2025-02-04_cli-v1-rollout.ts --resume=user-abc-123 -const resumeFromArg = process.argv.find(arg => arg.startsWith('--resume=')); -const RESUME_FROM_USER_ID = resumeFromArg ? resumeFromArg.split('=')[1] : null; - const BATCH_SIZE = 10; const SLEEP_AFTER_BATCH_MS = 1000; const CONCURRENT = 10; @@ -80,13 +75,9 @@ async function run() { const failedUserIds: string[] = []; const limit = pLimit(CONCURRENT); let globalBatchNumber = 0; - let lastUserId: string | null = RESUME_FROM_USER_ID; + let lastUserId: string | null = null; let hasMore = true; - if (RESUME_FROM_USER_ID) { - console.log(`RESUMING from user ID: ${RESUME_FROM_USER_ID}\n`); - } - console.log('Starting cursor-based pagination...\n'); // Step 3: Cursor-paginated loop over non-blocked users @@ -198,8 +189,6 @@ async function run() { console.log(` Skipped (never used Kilo): ${stats.skippedNeverUsed}`); console.log(` Failed: ${stats.failed}`); console.log(` Rate: ${usersPerSecond.toFixed(2)} users/sec`); - console.log(` Last user ID: ${lastUserId}`); - console.log(` To resume from this point: --resume=${lastUserId}`); if (hasMore || i + BATCH_SIZE < users.length) { await sleep(SLEEP_AFTER_BATCH_MS); @@ -226,7 +215,7 @@ async function run() { console.log('\nThis was a DRY RUN. No actual changes were made.'); console.log('To apply changes, run with --apply flag'); } else { - console.log(`\nTotal credits granted: $${stats.successful.toFixed(2)}`); + console.log(`\nTotal credits granted: ${stats.successful} users ($${stats.successful})`); } if (failedUserIds.length > 0) { From 9c533c587b11d32ad73f2db260887d22d15681f7 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 13:58:44 +0100 Subject: [PATCH 3/7] Refactor into two-phase cohort approach Phase 1 (cohort script): Tags eligible inactive users into a 'cli-v1-rollout' cohort via cheap SQL updates. Separates targeting logic from credit granting. Phase 2 (grant script): Iterates cohort members and grants credits. Simple, naturally resumable (idempotent grants skip already-credited users), and the cohort can be reused for mailing etc. --- .../d2025-02-04_cli-v1-rollout-cohort.ts | 116 +++++++++ .../d2025-02-04_cli-v1-rollout-grant.ts | 161 ++++++++++++ src/scripts/d2025-02-04_cli-v1-rollout.ts | 239 ------------------ 3 files changed, 277 insertions(+), 239 deletions(-) create mode 100644 src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts create mode 100644 src/scripts/d2025-02-04_cli-v1-rollout-grant.ts delete mode 100644 src/scripts/d2025-02-04_cli-v1-rollout.ts diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts new file mode 100644 index 000000000..7dfa7d527 --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts @@ -0,0 +1,116 @@ +import { db } from '@/lib/drizzle'; +import { kilocode_users, microdollar_usage } from '@/db/schema'; +import { sql, isNull, gte, inArray, notInArray, and, isNotNull } from 'drizzle-orm'; + +/** + * Phase 1: Tag eligible inactive users into the 'cli-v1-rollout' cohort. + * + * Eligible = non-blocked users who have used Kilo before but not in the last 30 days. + * + * This script does cheap SQL writes (UPDATE cohorts jsonb field) and does NOT + * grant any credits. Phase 2 (grant script) reads the cohort and grants credits. + * + * Usage: + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts # dry run + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts --apply # apply + */ + +const isDryRun = !process.argv.includes('--apply'); +const INACTIVE_DAYS = 30; +const COHORT_NAME = 'cli-v1-rollout'; + +async function run() { + console.log('Phase 1: Tagging inactive users into cohort...\n'); + + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to write cohort tags\n'); + } + + const cutoff = new Date(Date.now() - INACTIVE_DAYS * 24 * 60 * 60 * 1000); + + // Users active in last 30 days + console.log(`Fetching recently active user IDs (last ${INACTIVE_DAYS} days)...`); + const recentlyActiveRows = await db + .selectDistinct({ userId: microdollar_usage.kilo_user_id }) + .from(microdollar_usage) + .where(gte(microdollar_usage.created_at, cutoff.toISOString())); + + const recentlyActiveIds = recentlyActiveRows.map(r => r.userId); + console.log(`Found ${recentlyActiveIds.length} recently active users to EXCLUDE`); + + // Users who have ever used Kilo + console.log('Fetching all users who have ever used Kilo...'); + const everUsedRows = await db + .selectDistinct({ userId: microdollar_usage.kilo_user_id }) + .from(microdollar_usage); + + const everUsedIds = everUsedRows.map(r => r.userId); + console.log(`Found ${everUsedIds.length} users who have ever used Kilo`); + + // Eligible = ever used, not recently active, not blocked, not already in cohort + // Do this as a single UPDATE with subquery conditions + const now = Date.now(); + + if (isDryRun) { + // Count eligible users without writing + const eligible = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where( + and( + isNull(kilocode_users.blocked_reason), + inArray(kilocode_users.id, everUsedIds), + recentlyActiveIds.length > 0 + ? notInArray(kilocode_users.id, recentlyActiveIds) + : undefined, + sql`NOT (${kilocode_users.cohorts} ? ${COHORT_NAME})` + ) + ); + + console.log(`\nWould tag ${eligible.length} users into cohort '${COHORT_NAME}'`); + } else { + // Batch the update to avoid massive single transaction + // Process in chunks of everUsedIds since that's the IN clause + const BATCH_SIZE = 5000; + let totalTagged = 0; + + for (let i = 0; i < everUsedIds.length; i += BATCH_SIZE) { + const batch = everUsedIds.slice(i, i + BATCH_SIZE); + + const result = await db + .update(kilocode_users) + .set({ + cohorts: sql`${kilocode_users.cohorts} || jsonb_build_object(${COHORT_NAME}, ${now})`, + }) + .where( + and( + isNull(kilocode_users.blocked_reason), + inArray(kilocode_users.id, batch), + recentlyActiveIds.length > 0 + ? notInArray(kilocode_users.id, recentlyActiveIds) + : undefined, + sql`NOT (${kilocode_users.cohorts} ? ${COHORT_NAME})` + ) + ) + .returning({ id: kilocode_users.id }); + + totalTagged += result.length; + console.log( + `Batch ${Math.floor(i / BATCH_SIZE) + 1}: tagged ${result.length} users (total: ${totalTagged})` + ); + } + + console.log(`\nDone. Tagged ${totalTagged} users into cohort '${COHORT_NAME}'`); + } +} + +run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts new file mode 100644 index 000000000..f4ea787a5 --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts @@ -0,0 +1,161 @@ +import { db } from '@/lib/drizzle'; +import { kilocode_users, type User } from '@/db/schema'; +import { sql, gt, and } from 'drizzle-orm'; +import { grantCreditForCategory } from '@/lib/promotionalCredits'; +import pLimit from 'p-limit'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** + * Phase 2: Grant $1 credits to all users in the 'cli-v1-rollout' cohort. + * + * Reads users tagged by Phase 1 (cohort script) and grants them credits. + * Idempotent — safe to re-run; users who already received the credit are skipped. + * + * Usage: + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts # dry run + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts --apply # apply + */ + +const isDryRun = !process.argv.includes('--apply'); + +const BATCH_SIZE = 100; +const SLEEP_AFTER_BATCH_MS = 1000; +const CONCURRENT = 10; +const FETCH_BATCH_SIZE = 1000; +const COHORT_NAME = 'cli-v1-rollout'; + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function run() { + console.log('Phase 2: Granting credits to cohort members...'); + console.log(`Credit: $1 with 7-day expiry for users in cohort '${COHORT_NAME}'\n`); + + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to grant credits\n'); + } + + const scriptStartTime = Date.now(); + let processed = 0; + let successful = 0; + let skipped = 0; + let failed = 0; + const failedUserIds: string[] = []; + const limit = pLimit(CONCURRENT); + let lastUserId: string | null = null; + let hasMore = true; + + // Cursor-paginated loop over cohort members + while (hasMore) { + const cohortFilter = sql`${kilocode_users.cohorts} ? ${COHORT_NAME}`; + const users: User[] = await db.query.kilocode_users.findMany({ + where: lastUserId + ? and(cohortFilter, gt(kilocode_users.id, lastUserId)) + : cohortFilter, + orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], + limit: FETCH_BATCH_SIZE, + }); + + if (users.length === 0) { + hasMore = false; + break; + } + + lastUserId = users[users.length - 1].id; + hasMore = users.length === FETCH_BATCH_SIZE; + + console.log(`Fetched ${users.length} cohort members from database`); + + for (let i = 0; i < users.length; i += BATCH_SIZE) { + const batch = users.slice(i, Math.min(i + BATCH_SIZE, users.length)); + + const batchPromises = batch.map(user => + limit(async () => { + processed++; + + try { + if (isDryRun) { + successful++; + if (successful <= 100) { + console.log(` [DRY RUN] Would grant to: ${user.id} (${user.google_user_email})`); + } + return; + } + + const result = await grantCreditForCategory(user, { + credit_category: 'cli-v1-rollout', + counts_as_selfservice: false, + }); + + if (!result.success) { + if (result.message.includes('already been applied')) { + skipped++; + } else { + failed++; + failedUserIds.push(user.id); + console.log(` Failed for ${user.id}: ${result.message}`); + } + } else { + successful++; + if (successful <= 100 || successful % 1000 === 0) { + console.log(` Granted to: ${user.id} [#${successful}]`); + } + } + } catch (error) { + failed++; + failedUserIds.push(user.id); + console.error( + ` Error processing ${user.id}:`, + error instanceof Error ? error.message : String(error) + ); + } + }) + ); + + await Promise.all(batchPromises); + + const elapsedSeconds = (Date.now() - scriptStartTime) / 1000; + console.log( + `Progress: ${processed} processed, ${successful} granted, ${skipped} already applied, ${failed} failed (${(processed / elapsedSeconds).toFixed(1)} users/sec)` + ); + + if (hasMore || i + BATCH_SIZE < users.length) { + await sleep(SLEEP_AFTER_BATCH_MS); + } + } + } + + // Final report + const totalSeconds = (Date.now() - scriptStartTime) / 1000; + console.log('\n' + '='.repeat(60)); + console.log('Done!'); + console.log(` Processed: ${processed}`); + console.log(` Granted: ${successful}`); + console.log(` Already applied: ${skipped}`); + console.log(` Failed: ${failed}`); + console.log(` Time: ${totalSeconds.toFixed(1)}s`); + + if (isDryRun) { + console.log('\nThis was a DRY RUN. No actual changes were made.'); + } + + if (failedUserIds.length > 0) { + const logFileName = `failed-users-cli-v1-rollout-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const logFilePath = path.join(process.cwd(), logFileName); + await fs.writeFile(logFilePath, failedUserIds.join('\n') + '\n', 'utf-8'); + console.log(`\n${failedUserIds.length} failed user IDs written to: ${logFileName}`); + } +} + +run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); diff --git a/src/scripts/d2025-02-04_cli-v1-rollout.ts b/src/scripts/d2025-02-04_cli-v1-rollout.ts deleted file mode 100644 index aac5aecb0..000000000 --- a/src/scripts/d2025-02-04_cli-v1-rollout.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { db } from '@/lib/drizzle'; -import { kilocode_users, microdollar_usage, type User } from '@/db/schema'; -import { isNull, gt, and, gte } from 'drizzle-orm'; -import { grantCreditForCategory } from '@/lib/promotionalCredits'; -import pLimit from 'p-limit'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -const isDryRun = !process.argv.includes('--apply'); - -const BATCH_SIZE = 10; -const SLEEP_AFTER_BATCH_MS = 1000; -const CONCURRENT = 10; -const FETCH_BATCH_SIZE = 1000; -const INACTIVE_DAYS = 30; - -type ProcessingStats = { - processed: number; - successful: number; - skipped: number; - skippedActive: number; - skippedNeverUsed: number; - failed: number; -}; - -async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function run() { - console.log('Starting CLI V1 rollout credit distribution...'); - console.log('Credit: $1 with 7-day expiry for inactive users (no usage in last 30 days)\n'); - - if (isDryRun) { - console.log('DRY RUN MODE - No changes will be made to the database'); - console.log('Run with --apply flag to actually grant credits\n'); - } - - const scriptStartTime = Date.now(); - - // Step 1: Pre-compute recently active users (last 30 days) - console.log(`Fetching recently active user IDs (last ${INACTIVE_DAYS} days)...`); - const cutoff = new Date(Date.now() - INACTIVE_DAYS * 24 * 60 * 60 * 1000); - - const recentlyActiveRows = await db - .selectDistinct({ userId: microdollar_usage.kilo_user_id }) - .from(microdollar_usage) - .where(gte(microdollar_usage.created_at, cutoff.toISOString())); - - const recentlyActiveSet = new Set(recentlyActiveRows.map(r => r.userId)); - console.log(`Found ${recentlyActiveSet.size} recently active users to EXCLUDE`); - - // Step 2: Pre-compute users who have ever used Kilo - console.log('Fetching all users who have ever used Kilo...'); - const everUsedRows = await db - .selectDistinct({ userId: microdollar_usage.kilo_user_id }) - .from(microdollar_usage); - - const everUsedSet = new Set(everUsedRows.map(r => r.userId)); - console.log(`Found ${everUsedSet.size} users who have ever used Kilo`); - - // Eligible = ever used - recently active - const eligibleCount = [...everUsedSet].filter(id => !recentlyActiveSet.has(id)).length; - console.log(`Estimated eligible inactive users: ${eligibleCount}\n`); - - const stats: ProcessingStats = { - processed: 0, - successful: 0, - skipped: 0, - skippedActive: 0, - skippedNeverUsed: 0, - failed: 0, - }; - - const failedUserIds: string[] = []; - const limit = pLimit(CONCURRENT); - let globalBatchNumber = 0; - let lastUserId: string | null = null; - let hasMore = true; - - console.log('Starting cursor-based pagination...\n'); - - // Step 3: Cursor-paginated loop over non-blocked users - while (hasMore) { - const users: User[] = await db.query.kilocode_users.findMany({ - where: lastUserId - ? and(isNull(kilocode_users.blocked_reason), gt(kilocode_users.id, lastUserId)) - : isNull(kilocode_users.blocked_reason), - orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], - limit: FETCH_BATCH_SIZE, - }); - - if (users.length === 0) { - hasMore = false; - break; - } - - lastUserId = users[users.length - 1].id; - hasMore = users.length === FETCH_BATCH_SIZE; - - console.log(`\nFetched ${users.length} users from database`); - - for (let i = 0; i < users.length; i += BATCH_SIZE) { - const batch = users.slice(i, Math.min(i + BATCH_SIZE, users.length)); - globalBatchNumber++; - - console.log( - `\nProcessing batch ${globalBatchNumber} (${batch.length} users, total processed: ${stats.processed})...` - ); - - const batchPromises = batch.map(user => - limit(async () => { - stats.processed++; - - // Skip users who never used Kilo - if (!everUsedSet.has(user.id)) { - stats.skippedNeverUsed++; - return; - } - - // Skip users who are recently active - if (recentlyActiveSet.has(user.id)) { - stats.skippedActive++; - return; - } - - try { - if (isDryRun) { - stats.successful++; - if (stats.successful <= 100) { - console.log( - ` [DRY RUN] Would grant to: ${user.id} (${user.google_user_email})` - ); - } - return; - } - - const result = await grantCreditForCategory(user, { - credit_category: 'cli-v1-rollout', - counts_as_selfservice: false, - }); - - if (!result.success) { - const alreadyApplied = result.message.includes('already been applied'); - - if (alreadyApplied) { - stats.skipped++; - if (stats.skipped <= 100) { - console.log( - ` Skipped ${user.id} (${user.google_user_email}): Already applied` - ); - } - } else { - stats.failed++; - failedUserIds.push(user.id); - console.log( - ` Failed for ${user.id} (${user.google_user_email}): ${result.message}` - ); - } - } else { - stats.successful++; - if (stats.successful <= 100 || stats.successful % 1000 === 0) { - console.log( - ` Granted to: ${user.id} (${user.google_user_email}) [#${stats.successful}]` - ); - } - } - } catch (error) { - stats.failed++; - const errorMessage = error instanceof Error ? error.message : String(error); - failedUserIds.push(user.id); - console.error( - ` Error processing ${user.id} (${user.google_user_email}):`, - errorMessage - ); - } - }) - ); - - await Promise.all(batchPromises); - - const elapsedSeconds = (Date.now() - scriptStartTime) / 1000; - const usersPerSecond = stats.processed / elapsedSeconds; - - console.log(`\nProgress: ${stats.processed} users processed`); - console.log(` Successful: ${stats.successful}`); - console.log(` Skipped (already applied): ${stats.skipped}`); - console.log(` Skipped (recently active): ${stats.skippedActive}`); - console.log(` Skipped (never used Kilo): ${stats.skippedNeverUsed}`); - console.log(` Failed: ${stats.failed}`); - console.log(` Rate: ${usersPerSecond.toFixed(2)} users/sec`); - - if (hasMore || i + BATCH_SIZE < users.length) { - await sleep(SLEEP_AFTER_BATCH_MS); - } - } - } - - // Final report - const totalElapsedSeconds = (Date.now() - scriptStartTime) / 1000; - console.log('\n' + '='.repeat(60)); - console.log('Rollout completed!'); - console.log('='.repeat(60)); - console.log(`\nFinal Statistics:`); - console.log(` Total processed: ${stats.processed}`); - console.log(` Successful: ${stats.successful}`); - console.log(` Skipped (already applied): ${stats.skipped}`); - console.log(` Skipped (recently active): ${stats.skippedActive}`); - console.log(` Skipped (never used Kilo): ${stats.skippedNeverUsed}`); - console.log(` Failed: ${stats.failed}`); - console.log(` Total time: ${totalElapsedSeconds.toFixed(1)}s`); - console.log(` Average rate: ${(stats.processed / totalElapsedSeconds).toFixed(2)} users/sec`); - - if (isDryRun) { - console.log('\nThis was a DRY RUN. No actual changes were made.'); - console.log('To apply changes, run with --apply flag'); - } else { - console.log(`\nTotal credits granted: ${stats.successful} users ($${stats.successful})`); - } - - if (failedUserIds.length > 0) { - const logFileName = `failed-users-cli-v1-rollout-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; - const logFilePath = path.join(process.cwd(), logFileName); - const logContent = failedUserIds.join('\n') + '\n'; - - await fs.writeFile(logFilePath, logContent, 'utf-8'); - console.log(`\n${failedUserIds.length} failed user IDs written to: ${logFileName}`); - } -} - -run() - .then(() => { - console.log('\nScript completed successfully'); - process.exit(0); - }) - .catch(error => { - console.error('\nScript failed:', error); - process.exit(1); - }); From 08482d44b3bbebd5d1376b4bfaa53952a084916f Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 14:25:22 +0100 Subject: [PATCH 4/7] Address PR review comments: fix SQL syntax, add tests, simplify scripts - Fix SQL syntax in cohort UPDATE query (use plain column names in SET clause) - Add ::text cast for cohort name parameter to help PostgreSQL type inference - Fix typo: grantCreditsToCohor -> grantCreditsToCohort - Remove pLimit dependency and rate limiting (no longer needed without Orb) - Make scripts testable by guarding auto-execution - Add comprehensive integration tests for both phases and end-to-end flow --- .../d2025-02-04_cli-v1-rollout-cohort.ts | 153 +++++------ .../d2025-02-04_cli-v1-rollout-grant.ts | 184 ++++++------- src/tests/cli-v1-rollout.test.ts | 256 ++++++++++++++++++ 3 files changed, 401 insertions(+), 192 deletions(-) create mode 100644 src/tests/cli-v1-rollout.test.ts diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts index 7dfa7d527..3ead0dfae 100644 --- a/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts +++ b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts @@ -1,116 +1,97 @@ import { db } from '@/lib/drizzle'; import { kilocode_users, microdollar_usage } from '@/db/schema'; -import { sql, isNull, gte, inArray, notInArray, and, isNotNull } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; /** * Phase 1: Tag eligible inactive users into the 'cli-v1-rollout' cohort. * * Eligible = non-blocked users who have used Kilo before but not in the last 30 days. * - * This script does cheap SQL writes (UPDATE cohorts jsonb field) and does NOT - * grant any credits. Phase 2 (grant script) reads the cohort and grants credits. + * Uses subqueries so the database handles the filtering — no large arrays + * materialized in application memory. + * + * Idempotent: users already in the cohort are skipped via the + * NOT (cohorts ? ) guard. * * Usage: * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts # dry run * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts --apply # apply */ -const isDryRun = !process.argv.includes('--apply'); const INACTIVE_DAYS = 30; const COHORT_NAME = 'cli-v1-rollout'; -async function run() { - console.log('Phase 1: Tagging inactive users into cohort...\n'); +export async function tagInactiveUsersIntoCohort(options: { + cohortName: string; + inactiveDays: number; + dryRun: boolean; +}): Promise<{ tagged: number }> { + const cutoffIso = new Date(Date.now() - options.inactiveDays * 24 * 60 * 60 * 1000).toISOString(); + const now = Date.now(); - if (isDryRun) { - console.log('DRY RUN MODE - No changes will be made'); - console.log('Run with --apply flag to write cohort tags\n'); - } + const everUsed = sql`(SELECT DISTINCT ${microdollar_usage.kilo_user_id} FROM ${microdollar_usage})`; + const recentlyActive = sql`(SELECT DISTINCT ${microdollar_usage.kilo_user_id} FROM ${microdollar_usage} WHERE ${microdollar_usage.created_at} >= ${cutoffIso})`; - const cutoff = new Date(Date.now() - INACTIVE_DAYS * 24 * 60 * 60 * 1000); + if (options.dryRun) { + const countResult = await db.execute<{ count: string }>(sql` + SELECT COUNT(*) AS count + FROM ${kilocode_users} + WHERE ${kilocode_users.blocked_reason} IS NULL + AND ${kilocode_users.id} IN ${everUsed} + AND ${kilocode_users.id} NOT IN ${recentlyActive} + AND NOT (${kilocode_users.cohorts} ? ${options.cohortName}) + `); - // Users active in last 30 days - console.log(`Fetching recently active user IDs (last ${INACTIVE_DAYS} days)...`); - const recentlyActiveRows = await db - .selectDistinct({ userId: microdollar_usage.kilo_user_id }) - .from(microdollar_usage) - .where(gte(microdollar_usage.created_at, cutoff.toISOString())); + return { tagged: Number(countResult.rows[0].count) }; + } - const recentlyActiveIds = recentlyActiveRows.map(r => r.userId); - console.log(`Found ${recentlyActiveIds.length} recently active users to EXCLUDE`); + const result = await db.execute<{ count: string }>(sql` + WITH updated AS ( + UPDATE ${kilocode_users} + SET cohorts = cohorts || jsonb_build_object(${options.cohortName}::text, ${now}::bigint) + WHERE ${kilocode_users.blocked_reason} IS NULL + AND ${kilocode_users.id} IN ${everUsed} + AND ${kilocode_users.id} NOT IN ${recentlyActive} + AND NOT (cohorts ? ${options.cohortName}::text) + RETURNING ${kilocode_users.id} + ) + SELECT COUNT(*) AS count FROM updated + `); + + return { tagged: Number(result.rows[0].count) }; +} - // Users who have ever used Kilo - console.log('Fetching all users who have ever used Kilo...'); - const everUsedRows = await db - .selectDistinct({ userId: microdollar_usage.kilo_user_id }) - .from(microdollar_usage); +async function run() { + const isDryRun = !process.argv.includes('--apply'); - const everUsedIds = everUsedRows.map(r => r.userId); - console.log(`Found ${everUsedIds.length} users who have ever used Kilo`); + console.log('Phase 1: Tagging inactive users into cohort...\n'); + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to write cohort tags\n'); + } - // Eligible = ever used, not recently active, not blocked, not already in cohort - // Do this as a single UPDATE with subquery conditions - const now = Date.now(); + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: INACTIVE_DAYS, + dryRun: isDryRun, + }); if (isDryRun) { - // Count eligible users without writing - const eligible = await db - .select({ id: kilocode_users.id }) - .from(kilocode_users) - .where( - and( - isNull(kilocode_users.blocked_reason), - inArray(kilocode_users.id, everUsedIds), - recentlyActiveIds.length > 0 - ? notInArray(kilocode_users.id, recentlyActiveIds) - : undefined, - sql`NOT (${kilocode_users.cohorts} ? ${COHORT_NAME})` - ) - ); - - console.log(`\nWould tag ${eligible.length} users into cohort '${COHORT_NAME}'`); + console.log(`Would tag ${tagged} users into cohort '${COHORT_NAME}'`); } else { - // Batch the update to avoid massive single transaction - // Process in chunks of everUsedIds since that's the IN clause - const BATCH_SIZE = 5000; - let totalTagged = 0; - - for (let i = 0; i < everUsedIds.length; i += BATCH_SIZE) { - const batch = everUsedIds.slice(i, i + BATCH_SIZE); - - const result = await db - .update(kilocode_users) - .set({ - cohorts: sql`${kilocode_users.cohorts} || jsonb_build_object(${COHORT_NAME}, ${now})`, - }) - .where( - and( - isNull(kilocode_users.blocked_reason), - inArray(kilocode_users.id, batch), - recentlyActiveIds.length > 0 - ? notInArray(kilocode_users.id, recentlyActiveIds) - : undefined, - sql`NOT (${kilocode_users.cohorts} ? ${COHORT_NAME})` - ) - ) - .returning({ id: kilocode_users.id }); - - totalTagged += result.length; - console.log( - `Batch ${Math.floor(i / BATCH_SIZE) + 1}: tagged ${result.length} users (total: ${totalTagged})` - ); - } - - console.log(`\nDone. Tagged ${totalTagged} users into cohort '${COHORT_NAME}'`); + console.log(`Tagged ${tagged} users into cohort '${COHORT_NAME}'`); } } -run() - .then(() => { - console.log('\nScript completed successfully'); - process.exit(0); - }) - .catch(error => { - console.error('\nScript failed:', error); - process.exit(1); - }); +// Only run if executed directly (not imported as a module for testing) +if (require.main === module || process.argv[1]?.endsWith('d2025-02-04_cli-v1-rollout-cohort.ts')) { + run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); +} diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts index f4ea787a5..9328a7f91 100644 --- a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts +++ b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts @@ -2,7 +2,6 @@ import { db } from '@/lib/drizzle'; import { kilocode_users, type User } from '@/db/schema'; import { sql, gt, and } from 'drizzle-orm'; import { grantCreditForCategory } from '@/lib/promotionalCredits'; -import pLimit from 'p-limit'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -17,46 +16,33 @@ import * as path from 'node:path'; * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts --apply # apply */ -const isDryRun = !process.argv.includes('--apply'); - -const BATCH_SIZE = 100; -const SLEEP_AFTER_BATCH_MS = 1000; -const CONCURRENT = 10; -const FETCH_BATCH_SIZE = 1000; const COHORT_NAME = 'cli-v1-rollout'; -async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function run() { - console.log('Phase 2: Granting credits to cohort members...'); - console.log(`Credit: $1 with 7-day expiry for users in cohort '${COHORT_NAME}'\n`); - - if (isDryRun) { - console.log('DRY RUN MODE - No changes will be made'); - console.log('Run with --apply flag to grant credits\n'); - } - - const scriptStartTime = Date.now(); - let processed = 0; - let successful = 0; +export type GrantCohortCreditsResult = { + granted: number; + skipped: number; + failed: number; + failedUserIds: string[]; +}; + +export async function grantCreditsToCohort(options: { + cohortName: string; + creditCategory: string; +}): Promise { + let granted = 0; let skipped = 0; let failed = 0; const failedUserIds: string[] = []; - const limit = pLimit(CONCURRENT); + let lastUserId: string | null = null; let hasMore = true; - // Cursor-paginated loop over cohort members while (hasMore) { - const cohortFilter = sql`${kilocode_users.cohorts} ? ${COHORT_NAME}`; + const cohortFilter = sql`${kilocode_users.cohorts} ? ${options.cohortName}`; const users: User[] = await db.query.kilocode_users.findMany({ - where: lastUserId - ? and(cohortFilter, gt(kilocode_users.id, lastUserId)) - : cohortFilter, + where: lastUserId ? and(cohortFilter, gt(kilocode_users.id, lastUserId)) : cohortFilter, orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], - limit: FETCH_BATCH_SIZE, + limit: 1000, }); if (users.length === 0) { @@ -65,97 +51,83 @@ async function run() { } lastUserId = users[users.length - 1].id; - hasMore = users.length === FETCH_BATCH_SIZE; - - console.log(`Fetched ${users.length} cohort members from database`); - - for (let i = 0; i < users.length; i += BATCH_SIZE) { - const batch = users.slice(i, Math.min(i + BATCH_SIZE, users.length)); - - const batchPromises = batch.map(user => - limit(async () => { - processed++; - - try { - if (isDryRun) { - successful++; - if (successful <= 100) { - console.log(` [DRY RUN] Would grant to: ${user.id} (${user.google_user_email})`); - } - return; - } - - const result = await grantCreditForCategory(user, { - credit_category: 'cli-v1-rollout', - counts_as_selfservice: false, - }); - - if (!result.success) { - if (result.message.includes('already been applied')) { - skipped++; - } else { - failed++; - failedUserIds.push(user.id); - console.log(` Failed for ${user.id}: ${result.message}`); - } - } else { - successful++; - if (successful <= 100 || successful % 1000 === 0) { - console.log(` Granted to: ${user.id} [#${successful}]`); - } - } - } catch (error) { + hasMore = users.length === 1000; + + for (const user of users) { + try { + const result = await grantCreditForCategory(user, { + credit_category: options.creditCategory, + counts_as_selfservice: false, + }); + + if (!result.success) { + if (result.message.includes('already been applied')) { + skipped++; + } else { failed++; failedUserIds.push(user.id); - console.error( - ` Error processing ${user.id}:`, - error instanceof Error ? error.message : String(error) - ); } - }) - ); + } else { + granted++; + } + } catch { + failed++; + failedUserIds.push(user.id); + } + } + } - await Promise.all(batchPromises); + return { granted, skipped, failed, failedUserIds }; +} - const elapsedSeconds = (Date.now() - scriptStartTime) / 1000; - console.log( - `Progress: ${processed} processed, ${successful} granted, ${skipped} already applied, ${failed} failed (${(processed / elapsedSeconds).toFixed(1)} users/sec)` - ); +async function run() { + const isDryRun = !process.argv.includes('--apply'); - if (hasMore || i + BATCH_SIZE < users.length) { - await sleep(SLEEP_AFTER_BATCH_MS); - } - } + console.log('Phase 2: Granting credits to cohort members...'); + console.log(`Credit: $1 with 7-day expiry for users in cohort '${COHORT_NAME}'\n`); + + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to grant credits\n'); + + // In dry run, just count cohort members + const cohortFilter = sql`${kilocode_users.cohorts} ? ${COHORT_NAME}`; + const users = await db.query.kilocode_users.findMany({ where: cohortFilter }); + console.log(`Would grant credits to ${users.length} cohort members`); + return; } - // Final report + const scriptStartTime = Date.now(); + const result = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: COHORT_NAME, + }); + const totalSeconds = (Date.now() - scriptStartTime) / 1000; console.log('\n' + '='.repeat(60)); console.log('Done!'); - console.log(` Processed: ${processed}`); - console.log(` Granted: ${successful}`); - console.log(` Already applied: ${skipped}`); - console.log(` Failed: ${failed}`); + console.log(` Granted: ${result.granted}`); + console.log(` Already applied: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); console.log(` Time: ${totalSeconds.toFixed(1)}s`); - if (isDryRun) { - console.log('\nThis was a DRY RUN. No actual changes were made.'); - } - - if (failedUserIds.length > 0) { + if (result.failedUserIds.length > 0) { const logFileName = `failed-users-cli-v1-rollout-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; const logFilePath = path.join(process.cwd(), logFileName); - await fs.writeFile(logFilePath, failedUserIds.join('\n') + '\n', 'utf-8'); - console.log(`\n${failedUserIds.length} failed user IDs written to: ${logFileName}`); + await fs.writeFile(logFilePath, result.failedUserIds.join('\n') + '\n', 'utf-8'); + console.log(`\n${result.failedUserIds.length} failed user IDs written to: ${logFileName}`); } } -run() - .then(() => { - console.log('\nScript completed successfully'); - process.exit(0); - }) - .catch(error => { - console.error('\nScript failed:', error); - process.exit(1); - }); +// Only run if executed directly (not imported as a module for testing) +if (require.main === module || process.argv[1]?.endsWith('d2025-02-04_cli-v1-rollout-grant.ts')) { + run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); +} diff --git a/src/tests/cli-v1-rollout.test.ts b/src/tests/cli-v1-rollout.test.ts new file mode 100644 index 000000000..2c675d13f --- /dev/null +++ b/src/tests/cli-v1-rollout.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from '@jest/globals'; +import { db } from '@/lib/drizzle'; +import { kilocode_users, credit_transactions } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { insertTestUser } from './helpers/user.helper'; +import { insertUsageWithOverrides } from './helpers/microdollar-usage.helper'; +import { tagInactiveUsersIntoCohort } from '@/scripts/d2025-02-04_cli-v1-rollout-cohort'; +import { grantCreditsToCohort } from '@/scripts/d2025-02-04_cli-v1-rollout-grant'; +import { subDays } from 'date-fns'; + +const COHORT_NAME = 'test-cli-v1-rollout'; + +async function getUserCohorts(userId: string): Promise> { + const rows = await db + .select({ cohorts: kilocode_users.cohorts }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)); + return rows[0].cohorts; +} + +async function getUserCredits(userId: string, category: string) { + return db.select().from(credit_transactions).where(eq(credit_transactions.kilo_user_id, userId)); +} + +describe('CLI V1 Rollout', () => { + describe('Phase 1: tagInactiveUsersIntoCohort', () => { + it('should tag users who used Kilo >30 days ago but not recently', async () => { + const user = await insertTestUser(); + // Usage from 60 days ago + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + expect(tagged).toBeGreaterThanOrEqual(1); + const cohorts = await getUserCohorts(user.id); + expect(cohorts).toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag users who used Kilo recently', async () => { + const user = await insertTestUser(); + // Usage from 5 days ago (recent) + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 5).toISOString(), + }); + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag users who never used Kilo', async () => { + const user = await insertTestUser(); + // No usage records at all + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag blocked users', async () => { + const user = await insertTestUser({ blocked_reason: 'abuse' }); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should be idempotent — not re-tag already tagged users', async () => { + const user = await insertTestUser(); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + // First run + const first = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + expect(first.tagged).toBeGreaterThanOrEqual(1); + + const cohortsAfterFirst = await getUserCohorts(user.id); + const firstTimestamp = cohortsAfterFirst[COHORT_NAME]; + + // Second run — should not re-tag + const second = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + expect(second.tagged).toBe(0); + + // Timestamp should be unchanged + const cohortsAfterSecond = await getUserCohorts(user.id); + expect(cohortsAfterSecond[COHORT_NAME]).toBe(firstTimestamp); + }); + + it('should return count without writing in dry-run mode', async () => { + const user = await insertTestUser(); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: true, + }); + + expect(tagged).toBeGreaterThanOrEqual(1); + + // Should NOT have been tagged + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + }); + + describe('Phase 2: grantCreditsToCohort', () => { + it('should grant credits to cohort members', async () => { + const user = await insertTestUser(); + // Manually tag into cohort + await db + .update(kilocode_users) + .set({ cohorts: { [COHORT_NAME]: Date.now() } }) + .where(eq(kilocode_users.id, user.id)); + + const result = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + expect(result.granted).toBeGreaterThanOrEqual(1); + expect(result.failed).toBe(0); + }); + + it('should skip users who already received the credit (idempotent)', async () => { + const user = await insertTestUser(); + await db + .update(kilocode_users) + .set({ cohorts: { [COHORT_NAME]: Date.now() } }) + .where(eq(kilocode_users.id, user.id)); + + // First grant + const first = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + expect(first.granted).toBeGreaterThanOrEqual(1); + + // Second grant — should skip + const second = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + expect(second.skipped).toBeGreaterThanOrEqual(1); + expect(second.granted).toBe(0); + }); + + it('should not grant to users not in the cohort', async () => { + const user = await insertTestUser(); + // No cohort tag + + const result = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + // Should not have processed this user at all + const credits = await getUserCredits(user.id, 'cli-v1-rollout'); + const relevant = credits.filter(c => c.credit_category === 'cli-v1-rollout'); + expect(relevant).toHaveLength(0); + }); + }); + + describe('End-to-end: Phase 1 + Phase 2', () => { + it('should tag inactive users and then grant them credits', async () => { + const inactiveUser = await insertTestUser(); + const activeUser = await insertTestUser(); + const neverUsedUser = await insertTestUser(); + + // Inactive user: usage 60 days ago + await insertUsageWithOverrides({ + kilo_user_id: inactiveUser.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + // Active user: usage 5 days ago + await insertUsageWithOverrides({ + kilo_user_id: activeUser.id, + created_at: subDays(new Date(), 5).toISOString(), + }); + + // Never-used user: no usage records + + // Phase 1: Tag + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + // Verify only inactive user was tagged + expect(await getUserCohorts(inactiveUser.id)).toHaveProperty(COHORT_NAME); + expect(await getUserCohorts(activeUser.id)).not.toHaveProperty(COHORT_NAME); + expect(await getUserCohorts(neverUsedUser.id)).not.toHaveProperty(COHORT_NAME); + + // Phase 2: Grant + const grantResult = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + expect(grantResult.granted).toBeGreaterThanOrEqual(1); + expect(grantResult.failed).toBe(0); + + // Verify only inactive user got credits + const inactiveCredits = await getUserCredits(inactiveUser.id, 'cli-v1-rollout'); + expect(inactiveCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(1); + + const activeCredits = await getUserCredits(activeUser.id, 'cli-v1-rollout'); + expect(activeCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); + + const neverUsedCredits = await getUserCredits(neverUsedUser.id, 'cli-v1-rollout'); + expect(neverUsedCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); + }); + }); +}); From 3112ec684f3a7c0e6bb33174d35f7bb24f1411c9 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 14:39:45 +0100 Subject: [PATCH 5/7] Fix lint errors: remove unused parameter and variable --- src/tests/cli-v1-rollout.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tests/cli-v1-rollout.test.ts b/src/tests/cli-v1-rollout.test.ts index 2c675d13f..5afb702bf 100644 --- a/src/tests/cli-v1-rollout.test.ts +++ b/src/tests/cli-v1-rollout.test.ts @@ -18,7 +18,7 @@ async function getUserCohorts(userId: string): Promise> { return rows[0].cohorts; } -async function getUserCredits(userId: string, category: string) { +async function getUserCredits(userId: string) { return db.select().from(credit_transactions).where(eq(credit_transactions.kilo_user_id, userId)); } @@ -189,13 +189,13 @@ describe('CLI V1 Rollout', () => { const user = await insertTestUser(); // No cohort tag - const result = await grantCreditsToCohort({ + await grantCreditsToCohort({ cohortName: COHORT_NAME, creditCategory: 'cli-v1-rollout', }); // Should not have processed this user at all - const credits = await getUserCredits(user.id, 'cli-v1-rollout'); + const credits = await getUserCredits(user.id); const relevant = credits.filter(c => c.credit_category === 'cli-v1-rollout'); expect(relevant).toHaveLength(0); }); @@ -243,13 +243,13 @@ describe('CLI V1 Rollout', () => { expect(grantResult.failed).toBe(0); // Verify only inactive user got credits - const inactiveCredits = await getUserCredits(inactiveUser.id, 'cli-v1-rollout'); + const inactiveCredits = await getUserCredits(inactiveUser.id); expect(inactiveCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(1); - const activeCredits = await getUserCredits(activeUser.id, 'cli-v1-rollout'); + const activeCredits = await getUserCredits(activeUser.id); expect(activeCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); - const neverUsedCredits = await getUserCredits(neverUsedUser.id, 'cli-v1-rollout'); + const neverUsedCredits = await getUserCredits(neverUsedUser.id); expect(neverUsedCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); }); }); From 647cffb34bb654d04b981882f0f60f88a1264f84 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 15:13:47 +0100 Subject: [PATCH 6/7] Filter out blocked users in Phase 2 grant script --- src/scripts/d2025-02-04_cli-v1-rollout-grant.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts index 9328a7f91..cd2ff3a29 100644 --- a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts +++ b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts @@ -1,6 +1,6 @@ import { db } from '@/lib/drizzle'; import { kilocode_users, type User } from '@/db/schema'; -import { sql, gt, and } from 'drizzle-orm'; +import { sql, gt, and, isNull } from 'drizzle-orm'; import { grantCreditForCategory } from '@/lib/promotionalCredits'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -39,8 +39,10 @@ export async function grantCreditsToCohort(options: { while (hasMore) { const cohortFilter = sql`${kilocode_users.cohorts} ? ${options.cohortName}`; + const notBlocked = isNull(kilocode_users.blocked_reason); + const baseFilter = and(cohortFilter, notBlocked); const users: User[] = await db.query.kilocode_users.findMany({ - where: lastUserId ? and(cohortFilter, gt(kilocode_users.id, lastUserId)) : cohortFilter, + where: lastUserId ? and(baseFilter, gt(kilocode_users.id, lastUserId)) : baseFilter, orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], limit: 1000, }); From 3c7f560e13e325c2190df5bc74327932ec746893 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Thu, 5 Feb 2026 16:32:05 +0100 Subject: [PATCH 7/7] Add rollout README with instructions --- .../d2025-02-04_cli-v1-rollout-README.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/scripts/d2025-02-04_cli-v1-rollout-README.md diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-README.md b/src/scripts/d2025-02-04_cli-v1-rollout-README.md new file mode 100644 index 000000000..30fa1a07a --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-README.md @@ -0,0 +1,55 @@ +# CLI V1 Rollout - Inactive User Re-engagement + +Grant $1 credit (7-day expiry) to users who used Kilo before but not in the last 30 days. + +## Two-Phase Approach + +1. **Phase 1 (cohort):** Tags eligible users into `cli-v1-rollout` cohort via SQL +2. **Phase 2 (grant):** Grants credits to cohort members + +Both scripts are idempotent - safe to re-run. + +## Running the Scripts + +**Note:** Production env vars are required. Test `vercel env run` first: + +```bash +vercel env run --environment=production -- node -e "console.log(process.env.POSTGRES_URL ? 'OK' : 'NOT SET')" +``` + +### Phase 1: Tag Cohort + +```bash +# Dry run (default) - shows count of users that would be tagged +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts + +# Apply +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts --apply +``` + +### Phase 2: Grant Credits + +```bash +# Dry run (default) - shows count of cohort members +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts + +# Apply +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts --apply +``` + +## Rollout Checklist + +1. [ ] Test `vercel env run` with simple command +2. [ ] Run Phase 1 dry run, review user count +3. [ ] Run Phase 1 with `--apply` +4. [ ] Run Phase 2 dry run, verify cohort count matches +5. [ ] Run Phase 2 with `--apply` +6. [ ] Verify credits granted in admin panel + +## Fallback (if vercel env run doesn't work) + +```bash +vercel env pull .env.production +source .env.production # or use dotenv-cli +pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts +```