Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { deletionAttentionHint } from '@/lib/user/deletion-queue/deletion-hints'
import {
deletionStepDescription,
deletionStepLabel,
deletionStepProgressLabel,
formatActivityDetail,
formatAge,
formatTimestamp,
humanizeToken,
Expand Down Expand Up @@ -304,9 +306,7 @@ export function DeletionQueueDetailContent({
</span>
</div>
<p className="text-muted-foreground font-mono text-xs">
{[item.stepKey, item.details.errorCode, item.details.httpStatusClass]
.filter(Boolean)
.join(' · ') || '—'}
{formatActivityDetail(item)}
</p>
</div>
))
Expand Down Expand Up @@ -369,7 +369,6 @@ function CompactDeletionDetail({
}) {
const request = detail.request;
const ticket = request.pylonTicket ? `#${request.pylonTicket.replace(/^#/, '')}` : null;
const currentTask = detail.tasks.find(task => !isFinishedTask(task.status));
const stuckTask = detail.tasks.find(
task => task.status === 'needs_attention' || task.status === 'manual_action_required'
);
Expand Down Expand Up @@ -457,6 +456,7 @@ function CompactDeletionDetail({
return task ? [task] : [];
});
if (tasks.length === 0) return null;
const unlocked = isProgressGroupUnlocked(detail.tasks, groupIndex);
return (
<div key={group.label} className="flex flex-col gap-2">
{groupIndex > 0 ? (
Expand All @@ -479,7 +479,7 @@ function CompactDeletionDetail({
<ProgressStepTile
key={task.stepKey}
task={task}
current={currentTask?.stepKey === task.stepKey}
current={unlocked && isOpenTask(task.status)}
/>
))}
</div>
Expand All @@ -506,11 +506,7 @@ function CompactDeletionDetail({
<span className="font-medium">{humanizeToken(item.eventType)}</span>
<span className="text-muted-foreground">{formatTimestamp(item.createdAt)}</span>
</div>
<p className="text-muted-foreground font-mono">
{[item.stepKey ? deletionStepLabel(item.stepKey) : null, item.details.errorCode]
.filter(Boolean)
.join(' · ') || '—'}
</p>
<p className="text-muted-foreground font-mono">{formatActivityDetail(item)}</p>
</div>
))
)}
Expand Down Expand Up @@ -598,9 +594,39 @@ function isFinishedTask(status: string): boolean {
return status === 'succeeded' || status === 'not_applicable' || status === 'manually_verified';
}

function isStuckTask(status: string): boolean {
return status === 'needs_attention' || status === 'manual_action_required';
}

function isOpenTask(status: string): boolean {
return !isFinishedTask(status) && !isStuckTask(status);
}

function isProgressGroupUnlocked(tasks: Task[], groupIndex: number): boolean {
return PROGRESS_GROUPS.slice(0, groupIndex).every(group =>
group.stepKeys.every(stepKey => {
const task = tasks.find(item => item.stepKey === stepKey);
return !task || isFinishedTask(task.status);
})
);
}

function ProgressStepTile({ task, current }: { task: Task; current: boolean }) {
const finished = isFinishedTask(task.status);
const stuck = task.status === 'needs_attention' || task.status === 'manual_action_required';
const stuck = isStuckTask(task.status);
const countLabel = deletionStepProgressLabel(
task.stepKey,
task.processedCount,
task.scannedCount
);
const description =
stuck && task.lastErrorCode
? task.lastErrorCode
: current && countLabel
? `${countLabel} so far`
: finished && countLabel
? countLabel
: deletionStepDescription(task.stepKey);
return (
<div
className={cn(
Expand All @@ -609,19 +635,17 @@ function ProgressStepTile({ task, current }: { task: Task; current: boolean }) {
? 'border-status-success-border bg-status-success-surface'
: stuck
? 'border-status-warning-border bg-status-warning-surface'
: current && !finished
: current
? 'border-status-info-border bg-status-info-surface'
: 'border-border bg-card text-muted-foreground'
)}
>
<span className="shrink-0 font-semibold">
{finished ? <Check className="size-3.5" /> : current && !finished ? '▸' : '·'}
{finished ? <Check className="size-3.5" /> : current ? '▸' : '·'}
</span>
<div className="min-w-0">
<p className="text-foreground font-medium">{deletionStepLabel(task.stepKey)}</p>
<p className="text-muted-foreground mt-0.5">
{stuck && task.lastErrorCode ? task.lastErrorCode : deletionStepDescription(task.stepKey)}
</p>
<p className="text-muted-foreground mt-0.5">{description}</p>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { parseDeletionEntries, parseDeletionQueueTab } from './deletion-queue-format';
import {
deletionStepCountLabel,
deletionStepProgressLabel,
formatActivityDetail,
parseDeletionEntries,
parseDeletionQueueTab,
} from './deletion-queue-format';

describe('parseDeletionEntries', () => {
it('parses one email per line', () => {
Expand Down Expand Up @@ -50,6 +56,63 @@ describe('parseDeletionEntries', () => {
});
});

describe('deletionStepCountLabel', () => {
it('uses a step-specific verb for known cleanup tasks', () => {
expect(deletionStepCountLabel('cli_v2_sessions', 12)).toBe('12 deleted');
expect(deletionStepCountLabel('usage_prompt_prefixes', 340)).toBe('340 scrubbed');
expect(deletionStepCountLabel('kiloclaw_destroy', 2)).toBe('2 destroyed');
expect(deletionStepCountLabel('customerio', 1)).toBe('1 removed');
});
});

describe('deletionStepProgressLabel', () => {
it('shows scanned usage rows even when nothing was scrubbed', () => {
expect(deletionStepProgressLabel('usage_prompt_prefixes', 80, 49000)).toBe(
'80 scrubbed · 49000 scanned'
);
expect(deletionStepProgressLabel('usage_prompt_prefixes', 0, 1000)).toBe('1000 scanned');
expect(deletionStepProgressLabel('cli_v2_sessions', 0, 0)).toBeNull();
});
});

describe('formatActivityDetail', () => {
it('shows the step and how many records were processed', () => {
expect(
formatActivityDetail({
stepKey: 'cli_v1_blobs',
details: { processedCount: 3, errorCode: null },
})
).toBe('CLI v1 · 3 deleted');
});

it('includes zero counts so empty work is visible', () => {
expect(
formatActivityDetail({
stepKey: 'cli_v2_sessions',
details: { processedCount: 0, errorCode: null },
})
).toBe('CLI sessions · 0 deleted');
});

it('keeps error codes next to the count', () => {
expect(
formatActivityDetail({
stepKey: 'usage_prompt_prefixes',
details: { processedCount: 40, errorCode: 'usage_prefix_page_timeout' },
})
).toBe('Usage prompts · 40 scrubbed · usage_prefix_page_timeout');
});

it('shows scanned usage rows next to scrubbed prefixes', () => {
expect(
formatActivityDetail({
stepKey: 'usage_prompt_prefixes',
details: { processedCount: 80, scannedCount: 49000, errorCode: null },
})
).toBe('Usage prompts · 80 scrubbed · 49000 scanned');
});
});

describe('parseDeletionQueueTab', () => {
it('accepts the remaining tabs and falls back unknown values to open', () => {
expect(parseDeletionQueueTab('open')).toBe('open');
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/app/admin/deletion-queue/deletion-queue-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,58 @@ export function deletionStepDescription(stepKey: string): string {
return STEP_LABELS[stepKey]?.description ?? '';
}

export function deletionStepCountLabel(stepKey: string, count: number): string {
switch (stepKey) {
case 'usage_prompt_prefixes':
return `${count} scrubbed`;
case 'kiloclaw_destroy':
return `${count} destroyed`;
case 'customerio':
case 'substack':
return `${count} removed`;
default:
return `${count} deleted`;
}
}

export function deletionStepProgressLabel(
stepKey: string,
processedCount: number,
scannedCount = 0
): string | null {
const parts: string[] = [];
if (processedCount > 0) parts.push(deletionStepCountLabel(stepKey, processedCount));
if (scannedCount > 0) parts.push(`${scannedCount} scanned`);
return parts.length > 0 ? parts.join(' · ') : null;
}

export function formatActivityDetail(item: {
stepKey: string | null;
details: {
processedCount: number | null;
scannedCount?: number | null;
errorCode: string | null;
httpStatusClass?: string | null;
};
}): string {
const count =
item.stepKey && item.details.processedCount != null
? deletionStepCountLabel(item.stepKey, item.details.processedCount)
: null;
const scanned = item.details.scannedCount != null ? `${item.details.scannedCount} scanned` : null;
return (
[
item.stepKey ? deletionStepLabel(item.stepKey) : null,
count,
scanned,
item.details.errorCode,
item.details.httpStatusClass,
]
.filter(Boolean)
.join(' · ') || '—'
);
}

export function shortId(id: string): string {
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
}
Expand Down
80 changes: 80 additions & 0 deletions apps/web/src/lib/user/deletion-queue/deletion-outcomes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,60 @@ import { cleanupDbForTest, db } from '@/lib/drizzle';
import { enqueueUserDeletionTargets } from '@/lib/user/deletion-queue/deletion-enqueue';
import {
markTaskManuallyVerified,
persistHandlerOutcome,
persistRejectedPreflight,
retryBlockedPreflight,
} from '@/lib/user/deletion-queue/deletion-outcomes';
import { runDeletionPreflight } from '@/lib/user/deletion-queue/deletion-preflight';
import { insertTestUser } from '@/tests/helpers/user.helper';

describe('persistHandlerOutcome progress', () => {
beforeEach(async () => {
await cleanupDbForTest();
});

it('writes retry progress onto the step', async () => {
const { requestId, claimToken } = await enqueueRunningStep(UserDeletionStepKey.CliV2Sessions);

const result = await persistHandlerOutcome({
requestId,
stepKey: UserDeletionStepKey.CliV2Sessions,
claimToken,
outcome: {
kind: 'retry',
errorCode: 'http_500',
httpStatusClass: '5xx',
progress: { processed_count: 10 },
},
});

expect(result.kind).toBe('applied');
const step = await loadStep(requestId, UserDeletionStepKey.CliV2Sessions);
expect(step?.status).toBe(UserDeletionStepStatus.RetryWait);
expect(step?.progress_json).toEqual({ processed_count: 10 });
});

it('writes needs_attention progress onto the step', async () => {
const { requestId, claimToken } = await enqueueRunningStep(UserDeletionStepKey.CliV2Sessions);

const result = await persistHandlerOutcome({
requestId,
stepKey: UserDeletionStepKey.CliV2Sessions,
claimToken,
outcome: {
kind: 'needs_attention',
errorCode: 'session_identity_mismatch',
progress: { processed_count: 7 },
},
});

expect(result.kind).toBe('applied');
const step = await loadStep(requestId, UserDeletionStepKey.CliV2Sessions);
expect(step?.status).toBe(UserDeletionStepStatus.NeedsAttention);
expect(step?.progress_json).toEqual({ processed_count: 7 });
});
});

describe('markTaskManuallyVerified', () => {
beforeEach(async () => {
await cleanupDbForTest();
Expand Down Expand Up @@ -288,6 +336,38 @@ describe('shared preflight outcomes', () => {
});
});

async function enqueueRunningStep(stepKey: UserDeletionStepKey) {
const admin = await insertTestUser({ is_admin: true });
const user = await insertTestUser({
google_user_email: `running-${stepKey}-${crypto.randomUUID()}@example.com`,
});
const [result] = await enqueueUserDeletionTargets({
actor: { kiloUserId: admin.id },
targets: [{ email: user.google_user_email, trustedUserId: user.id }],
});
expect(result.status).toBe('enqueued');
if (result.status !== 'enqueued') throw new Error('expected enqueued');
const claimToken = crypto.randomUUID();
await db
.update(user_deletion_requests)
.set({ status: UserDeletionRequestStatus.InProgress })
.where(eq(user_deletion_requests.id, result.requestId));
await db
.update(user_deletion_steps)
.set({
status: UserDeletionStepStatus.Running,
claim_token: claimToken,
claimed_until: new Date(Date.now() + 60_000).toISOString(),
})
.where(
and(
eq(user_deletion_steps.request_id, result.requestId),
eq(user_deletion_steps.step_key, stepKey)
)
);
return { requestId: result.requestId, claimToken };
}

async function enqueueStuckStep(params: {
stepKey: UserDeletionStepKey;
status: UserDeletionStepStatus;
Expand Down
Loading