Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { Config } from 'jest';

const config: Config = {
roots: ['<rootDir>', '<rootDir>/../../packages/db/src'],
testEnvironment: 'node',
transform: {
'^.+\\.tsx$': [
Expand Down
239 changes: 239 additions & 0 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ import {
user_terms_acceptances,
quick_chat_threads,
quick_chat_messages,
agent_harness_clients,
agent_harness_conversation_grants,
agent_harness_conversation_registry,
agent_harness_invitation_results,
agent_harness_retirements,
user_deletion_requests,
user_deletion_steps,
cloud_agent_pending_uploads,
Expand Down Expand Up @@ -306,6 +311,11 @@ describe('User', () => {
await db.delete(platform_oauth_credentials);
await db.delete(platform_access_token_credentials);
await db.delete(platform_integrations);
await db.delete(agent_harness_retirements);
await db.delete(agent_harness_conversation_registry);
await db.delete(agent_harness_invitation_results);
await db.delete(agent_harness_conversation_grants);
await db.delete(agent_harness_clients);
await db.delete(quick_chat_messages);
await db.delete(quick_chat_threads);
await db.delete(organizations);
Expand Down Expand Up @@ -1683,6 +1693,235 @@ describe('User', () => {
.from(quick_chat_messages)
.where(eq(quick_chat_messages.thread_id, otherThread.id))
).toHaveLength(1);
expect(
await db
.select()
.from(agent_harness_retirements)
.where(inArray(agent_harness_retirements.thread_id, [thread.id, otherThread.id]))
).toEqual([
expect.objectContaining({ thread_id: thread.id, generation: 0, reason: 'account_deleted' }),
]);
});

it('deletes harness personal payloads and retains minimal fences, including orphaned coordinators', async () => {
const user = await insertTestUser({ id: `oauth/github:harness-cleanup-${randomUUID()}` });
const otherUser = await insertTestUser();
const organization = await createTestOrganization('Harness cleanup', otherUser.id, 0);
const personalThreadId = randomUUID();
const organizationThreadId = randomUUID();
const orphanThreadId = randomUUID();
const otherThreadId = randomUUID();
const clientId = randomUUID();
const otherClientId = randomUUID();
const deletedThreadIds = [personalThreadId, organizationThreadId, orphanThreadId];
const allThreadIds = [...deletedThreadIds, otherThreadId];
const threads = [
{ id: personalThreadId, user_id: user.id, organization_id: null, generation: 2 },
{
id: organizationThreadId,
user_id: user.id,
organization_id: organization.id,
generation: 3,
},
{ id: otherThreadId, user_id: otherUser.id, organization_id: null, generation: 4 },
];
await db
.insert(quick_chat_threads)
.values(
threads.map(({ id, user_id, organization_id }) => ({ id, user_id, organization_id }))
);
await db.insert(agent_harness_conversation_registry).values([
...threads.map(({ id, ...thread }) => ({ thread_id: id, ...thread })),
{
thread_id: orphanThreadId,
user_id: user.id,
organization_id: organization.id,
generation: 7,
},
]);
await db.insert(agent_harness_clients).values([
{
id: clientId,
user_id: user.id,
kind: 'mobile',
session_binding: 'deleted-session-reference',
},
{
id: otherClientId,
user_id: otherUser.id,
kind: 'browser',
session_binding: 'kept-session-reference',
},
]);
await db.insert(agent_harness_conversation_grants).values(
threads.map(thread => ({
thread_id: thread.id,
user_id: thread.user_id,
client_id: thread.user_id === user.id ? clientId : otherClientId,
generation: thread.generation,
expires_at: '2100-01-01T00:00:00.000Z',
}))
);
await db.insert(agent_harness_invitation_results).values(
threads.map(thread => {
const invitationId = randomUUID();
return {
thread_id: thread.id,
operation_id: randomUUID(),
input_digest: `input-${thread.id}`,
invitation_id: invitationId,
canonical_result: {
invitationId,
acceptInviteUrl: `https://example.com/invite/${randomUUID()}`,
emailStatus: 'pending' as const,
},
};
})
);
await db.insert(quick_chat_messages).values(
threads.map(thread => ({
thread_id: thread.id,
role: 'user',
content: `private-${thread.id}`,
}))
);

await expect(softDeleteUser(user.id)).resolves.toBeUndefined();

expect(
await db
.select()
.from(agent_harness_clients)
.where(inArray(agent_harness_clients.id, [clientId, otherClientId]))
).toEqual([
expect.objectContaining({ id: otherClientId, session_binding: 'kept-session-reference' }),
]);
expect(
await db
.select()
.from(agent_harness_conversation_grants)
.where(inArray(agent_harness_conversation_grants.thread_id, allThreadIds))
).toEqual([expect.objectContaining({ thread_id: otherThreadId })]);
expect(
await db
.select()
.from(agent_harness_invitation_results)
.where(inArray(agent_harness_invitation_results.thread_id, allThreadIds))
).toEqual([expect.objectContaining({ thread_id: otherThreadId })]);
expect(
await db
.select()
.from(quick_chat_messages)
.where(inArray(quick_chat_messages.thread_id, allThreadIds))
).toEqual([
expect.objectContaining({ thread_id: otherThreadId, content: `private-${otherThreadId}` }),
]);
expect(
await db
.select()
.from(quick_chat_threads)
.where(inArray(quick_chat_threads.id, allThreadIds))
).toEqual([expect.objectContaining({ id: otherThreadId })]);
expect(
await db
.select({
thread_id: agent_harness_conversation_registry.thread_id,
generation: agent_harness_conversation_registry.generation,
user_id: agent_harness_conversation_registry.user_id,
organization_id: agent_harness_conversation_registry.organization_id,
})
.from(agent_harness_conversation_registry)
.where(inArray(agent_harness_conversation_registry.thread_id, deletedThreadIds))
).toEqual(
expect.arrayContaining([
{ thread_id: personalThreadId, generation: 2, user_id: null, organization_id: null },
{ thread_id: organizationThreadId, generation: 3, user_id: null, organization_id: null },
{ thread_id: orphanThreadId, generation: 7, user_id: null, organization_id: null },
])
);
expect(
await db
.select()
.from(agent_harness_conversation_registry)
.where(eq(agent_harness_conversation_registry.thread_id, otherThreadId))
).toEqual([expect.objectContaining({ user_id: otherUser.id, generation: 4 })]);
const fences = await db
.select()
.from(agent_harness_retirements)
.where(inArray(agent_harness_retirements.thread_id, allThreadIds));
expect(fences).toHaveLength(3);
expect(fences).toEqual(
expect.arrayContaining([
expect.objectContaining({
thread_id: personalThreadId,
generation: 2,
reason: 'account_deleted',
}),
expect.objectContaining({
thread_id: organizationThreadId,
generation: 3,
reason: 'account_deleted',
}),
expect.objectContaining({
thread_id: orphanThreadId,
generation: 7,
reason: 'account_deleted',
}),
])
);
await db
.update(agent_harness_retirements)
.set({ acknowledged_at: sql`now()` })
.where(eq(agent_harness_retirements.thread_id, personalThreadId));
const readFences = () =>
db
.select()
.from(agent_harness_retirements)
.where(inArray(agent_harness_retirements.thread_id, deletedThreadIds))
.orderBy(agent_harness_retirements.thread_id);
const acknowledgedFences = await readFences();
await expect(softDeleteUser(user.id)).resolves.toBeUndefined();
expect(await readFences()).toEqual(acknowledgedFences);
});

it('rolls back harness fences and payload cleanup with the caller transaction', async () => {
const user = await insertTestUser();
const threadId = randomUUID();
const clientId = randomUUID();
await db.insert(quick_chat_threads).values({ id: threadId, user_id: user.id });
await db
.insert(agent_harness_conversation_registry)
.values({ thread_id: threadId, user_id: user.id });
await db.insert(agent_harness_clients).values({
id: clientId,
user_id: user.id,
kind: 'browser',
session_binding: 'original-session-reference',
});
await expect(
db.transaction(async tx => {
await anonymizeCloudUserData(tx, user.id);
throw new Error('Abort account cleanup');
})
).rejects.toThrow('Abort account cleanup');
expect(
await db.select().from(quick_chat_threads).where(eq(quick_chat_threads.id, threadId))
).toEqual([expect.objectContaining({ user_id: user.id })]);
expect(
await db.select().from(agent_harness_clients).where(eq(agent_harness_clients.id, clientId))
).toEqual([expect.objectContaining({ session_binding: 'original-session-reference' })]);
expect(
await db
.select()
.from(agent_harness_conversation_registry)
.where(eq(agent_harness_conversation_registry.thread_id, threadId))
).toEqual([expect.objectContaining({ user_id: user.id })]);
expect(
await db
.select()
.from(agent_harness_retirements)
.where(eq(agent_harness_retirements.thread_id, threadId))
).toEqual([]);
});

it('deletes user data export state and dependent multipart and outbox rows', async () => {
Expand Down
50 changes: 39 additions & 11 deletions apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ import {
user_terms_acceptances,
quick_chat_threads,
quick_chat_messages,
agent_harness_clients,
agent_harness_conversation_grants,
agent_harness_conversation_registry,
agent_harness_retirements,
} from '@kilocode/db/schema';
import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count, ne } from 'drizzle-orm';
import { allow_fake_login, IS_DEVELOPMENT } from '@/lib/constants';
Expand Down Expand Up @@ -1051,7 +1055,9 @@ export async function assertUserCanBeSoftDeleted(userId: string): Promise<void>
* device_auth_requests, auto_top_up_configs,
* user_github_app_tokens, kiloclaw_instances/inbound_email_aliases/access_codes,
* user_period_cache, kilo_pass_scheduled_changes, coding_plan_availability_intents,
* user_notification_preferences, quick_chat_threads, quick_chat_messages)
* user_notification_preferences, quick_chat_threads, quick_chat_messages,
* agent_harness_clients, agent_harness_conversation_grants, agent_harness_invitation_results)
* - Harness registry owner/context links; retain only coordinator discovery and retirement identifiers.
* - operation_ledgers (keyed by kilo_user_id)
* - analytics_event_outbox (keyed by distinct_id: the user's email or, when the
* writer's email lookup failed, the user id)
Expand Down Expand Up @@ -1485,20 +1491,42 @@ export async function anonymizeCloudUserData(
await tx.delete(user_moderation_mutes).where(eq(user_moderation_mutes.blocker_user_id, userId));
await tx.delete(user_terms_acceptances).where(eq(user_terms_acceptances.kilo_user_id, userId));

// Quick chat threads and messages are user-owned, so they are hard-deleted
// with the account. Messages go first so the thread delete below cannot race
// a cascade that would leave them behind.
const userThreadIds = tx
.select({ id: quick_chat_threads.id })
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, userId));

// Persist fences before deleting payloads, including coordinators whose thread already cascaded away.
// Old threads have no registry generation. Keep generation zero until old threads and writers are gone.
await tx.execute(sql`
INSERT INTO ${agent_harness_retirements} (thread_id, generation, reason)
SELECT thread.id, COALESCE(registry.generation, 0), 'account_deleted'
FROM ${quick_chat_threads} AS thread
LEFT JOIN ${agent_harness_conversation_registry} AS registry ON registry.thread_id = thread.id
WHERE thread.user_id = ${userId}
UNION
SELECT thread_id, generation, 'account_deleted'
FROM ${agent_harness_conversation_registry}
WHERE user_id = ${userId}
ON CONFLICT (thread_id, generation) DO NOTHING
`);
await tx
.delete(quick_chat_messages)
.update(agent_harness_conversation_registry)
.set({ user_id: null, organization_id: null })
.where(
inArray(
quick_chat_messages.thread_id,
tx
.select({ id: quick_chat_threads.id })
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, userId))
or(
eq(agent_harness_conversation_registry.user_id, userId),
inArray(agent_harness_conversation_registry.thread_id, userThreadIds)
)
);
await tx
.delete(agent_harness_conversation_grants)
.where(eq(agent_harness_conversation_grants.user_id, userId));
await tx.delete(agent_harness_clients).where(eq(agent_harness_clients.user_id, userId));

// Thread deletion also deletes conversation grants and canonical invitation results.
// Discovery rows and retirement fences have no cascading foreign keys.
await tx.delete(quick_chat_messages).where(inArray(quick_chat_messages.thread_id, userThreadIds));
await tx.delete(quick_chat_threads).where(eq(quick_chat_threads.user_id, userId));

// Code indexing data
Expand Down
Loading