Skip to content
Open
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
53 changes: 53 additions & 0 deletions apps/web/src/lib/kiloclaw/instance-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const INSTANCE_LIFECYCLE_ACTOR = {
actorType: 'system',
actorId: 'web-instance-lifecycle',
} as const;
const INSTANCE_DESTROYED_REASON = 'instance_destroyed';

type ActiveInstance = {
id: string;
Expand Down Expand Up @@ -70,6 +71,58 @@ function subscriptionFilterForUser(kiloUserId: string, instanceId?: string) {
);
}

export async function clearSubscriptionLifecycleAfterInstanceDestroy(params: {
actorUserId: string;
kiloUserId: string;
instanceId: string;
}): Promise<void> {
await db.transaction(async tx => {
const [subscription] = await tx
.select()
.from(kiloclaw_subscriptions)
.where(subscriptionFilterForUser(params.kiloUserId, params.instanceId))
.limit(1);

if (!subscription) {
return;
}

const clearFields: { destruction_deadline: null; suspended_at?: null } = {
destruction_deadline: null,
};

if (subscription.status !== 'past_due') {
clearFields.suspended_at = null;
}

const [updatedSubscription] = await tx
.update(kiloclaw_subscriptions)
.set(clearFields)
.where(eq(kiloclaw_subscriptions.id, subscription.id))
.returning();

const clearedSuspension =
subscription.destruction_deadline !== null ||
(subscription.status !== 'past_due' && subscription.suspended_at !== null);

if (!updatedSubscription || !clearedSuspension) {
return;
}

await insertKiloClawSubscriptionChangeLog(tx, {
subscriptionId: subscription.id,
actor: {
actorType: 'user',
actorId: params.actorUserId,
},
action: 'status_changed',
reason: INSTANCE_DESTROYED_REASON,
before: subscription,
after: updatedSubscription,
});
});
}

async function clearAutoResumeState(
kiloUserId: string,
options: {
Expand Down
111 changes: 107 additions & 4 deletions apps/web/src/routers/kiloclaw-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
process.env.STRIPE_KILOCLAW_COMMIT_PRICE_ID ||= 'price_commit';
process.env.STRIPE_KILOCLAW_STANDARD_PRICE_ID ||= 'price_standard';
process.env.STRIPE_KILOCLAW_STANDARD_INTRO_PRICE_ID ||= 'price_standard_intro';
process.env.KILOCLAW_API_URL ||= 'https://claw.test';
process.env.KILOCLAW_INTERNAL_API_SECRET ||= 'test-secret';

import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { cleanupDbForTest, db } from '@/lib/drizzle';
import { createCallerFactory } from '@/lib/trpc/init';
import { kiloclawRouter } from '@/routers/kiloclaw-router';
import { insertTestUser } from '@/tests/helpers/user.helper';
import {
kiloclaw_inbound_email_aliases,
kiloclaw_inbound_email_reserved_aliases,
kiloclaw_instances,
kiloclaw_subscription_change_log,
kiloclaw_subscriptions,
} from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';

Expand All @@ -20,6 +23,7 @@ type AnyMock = jest.Mock<(...args: any[]) => any>;
type KiloClawClientMock = {
KiloClawInternalClient: AnyMock;
__getStatusMock: AnyMock;
__destroyMock: AnyMock;
};

jest.mock('@/lib/stripe-client', () => {
Expand Down Expand Up @@ -60,11 +64,23 @@ jest.mock('next/headers', () => {
};
});

jest.mock('@/lib/config.server', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = jest.requireActual<typeof import('@/lib/config.server')>('@/lib/config.server');
return {
...actual,
KILOCLAW_API_URL: 'https://claw.test',
KILOCLAW_INTERNAL_API_SECRET: 'test-secret',
};
});

jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
const getStatusMock = jest.fn();
const destroyMock = jest.fn();
return {
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
getStatus: getStatusMock,
destroy: destroyMock,
})),
KiloClawApiError: class KiloClawApiError extends Error {
statusCode: number;
Expand All @@ -76,19 +92,30 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
}
},
__getStatusMock: getStatusMock,
__destroyMock: destroyMock,
};
});

const createCaller = createCallerFactory(kiloclawRouter);
let createCaller: (ctx: { user: Awaited<ReturnType<typeof insertTestUser>> }) => {
getStatus: () => Promise<unknown>;
cycleInboundEmailAddress: () => Promise<{ inboundEmailAddress: string }>;
destroy: () => Promise<{ ok: true }>;
};
const kiloclawClientMock = jest.requireMock<KiloClawClientMock>(
'@/lib/kiloclaw/kiloclaw-internal-client'
);

beforeAll(async () => {
const mod = await import('@/routers/kiloclaw-router');
createCaller = createCallerFactory(mod.kiloclawRouter);
});

describe('kiloclawRouter getStatus', () => {
beforeEach(async () => {
await cleanupDbForTest();
kiloclawClientMock.KiloClawInternalClient.mockClear();
kiloclawClientMock.__getStatusMock.mockReset();
kiloclawClientMock.__destroyMock.mockReset();
});

it('returns a no-instance sentinel without querying the legacy worker path', async () => {
Expand Down Expand Up @@ -127,7 +154,7 @@ describe('kiloclawRouter getStatus', () => {
botNature: null,
botVibe: null,
botEmoji: null,
workerUrl: 'https://claw.kilo.ai',
workerUrl: 'https://claw.test',
name: null,
instanceId: null,
inboundEmailAddress: null,
Expand Down Expand Up @@ -162,3 +189,79 @@ describe('kiloclawRouter getStatus', () => {
expect(rows.filter(row => row.retired_at === null)).toHaveLength(1);
});
});

describe('kiloclawRouter destroy', () => {
beforeEach(async () => {
await cleanupDbForTest();
kiloclawClientMock.KiloClawInternalClient.mockClear();
kiloclawClientMock.__destroyMock.mockReset();
kiloclawClientMock.__destroyMock.mockResolvedValue({ ok: true });
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
});

it('clears subscription destruction lifecycle and writes changelog', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-destroy-test-${Math.random()}@example.com`,
});
const instanceId = crypto.randomUUID();
await db.insert(kiloclaw_instances).values({
id: instanceId,
user_id: user.id,
sandbox_id: `ki_${instanceId.replace(/-/g, '')}`,
});
await db.insert(kiloclaw_subscriptions).values({
user_id: user.id,
instance_id: instanceId,
plan: 'standard',
status: 'active',
suspended_at: '2026-04-10T00:00:00.000Z',
destruction_deadline: '2026-04-12T00:00:00.000Z',
});

const caller = createCaller({ user });
const result = await caller.destroy();

expect(result).toEqual({ ok: true });

const [subscription] = await db
.select()
.from(kiloclaw_subscriptions)
.where(eq(kiloclaw_subscriptions.instance_id, instanceId))
.limit(1);

expect(subscription.suspended_at).toBeNull();
expect(subscription.destruction_deadline).toBeNull();

const logs = await db
.select()
.from(kiloclaw_subscription_change_log)
.where(eq(kiloclaw_subscription_change_log.subscription_id, subscription.id));

expect(logs).toHaveLength(1);
expect(logs[0]).toEqual(
expect.objectContaining({
actor_type: 'user',
actor_id: user.id,
action: 'status_changed',
reason: 'instance_destroyed',
})
);
expect(logs[0]?.before_state).toEqual(
expect.objectContaining({
suspended_at: expect.stringContaining('2026-04-10'),
destruction_deadline: expect.stringContaining('2026-04-12'),
})
);
expect(logs[0]?.after_state).toEqual(
expect.objectContaining({
suspended_at: null,
destruction_deadline: null,
})
);
});
});
33 changes: 6 additions & 27 deletions apps/web/src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
getPersonalProvisionLockKey,
withKiloclawProvisionContextLock,
} from '@/lib/kiloclaw/provision-lock';
import { clearSubscriptionLifecycleAfterInstanceDestroy } from '@/lib/kiloclaw/instance-lifecycle';

import { dayjs } from '@/lib/kilo-pass/dayjs';
import {
Expand Down Expand Up @@ -2042,34 +2043,12 @@ export const kiloclawRouter = createTRPCRouter({
// send warning emails or attempt a redundant destroy.
// Current billing row stays anchored to destroyed instance until
// reprovision bootstrap creates successor row on next provision.
// Only clear suspended_at for non-past_due subscriptions — nulling it
// on a past_due row would re-enable access without fixing payment.
if (destroyedRow) {
const [sub] = await db
.select({ status: kiloclaw_subscriptions.status })
.from(kiloclaw_subscriptions)
.where(
and(
eq(kiloclaw_subscriptions.user_id, ctx.user.id),
eq(kiloclaw_subscriptions.instance_id, destroyedRow.id)
)
)
.limit(1);
const clearFields: { destruction_deadline: null; suspended_at?: null } = {
destruction_deadline: null,
};
if (sub && sub.status !== 'past_due') {
clearFields.suspended_at = null;
}
await db
.update(kiloclaw_subscriptions)
.set(clearFields)
.where(
and(
eq(kiloclaw_subscriptions.user_id, ctx.user.id),
eq(kiloclaw_subscriptions.instance_id, destroyedRow.id)
)
);
await clearSubscriptionLifecycleAfterInstanceDestroy({
actorUserId: ctx.user.id,
kiloUserId: ctx.user.id,
instanceId: destroyedRow.id,
});
}

// Clear lifecycle emails so they can fire again if the user re-provisions.
Expand Down
Loading