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
740 changes: 740 additions & 0 deletions apps/web/src/lib/kiloclaw/cli-runs.test.ts

Large diffs are not rendered by default.

427 changes: 427 additions & 0 deletions apps/web/src/lib/kiloclaw/cli-runs.ts

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions apps/web/src/lib/kiloclaw/instance-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ export function workerInstanceId(
return sandboxId.startsWith('ki_') ? instance.id : undefined;
}

/**
* Resolve the worker instance ID for DO routing from a database instance row ID.
* Unlike {@link getInstanceById}, this includes destroyed instances — needed
* when routing requests for historical runs whose instance has been torn down.
*/
export async function resolveWorkerInstanceId(instanceId: string): Promise<string | undefined> {
const [row] = await db
.select({
id: kiloclaw_instances.id,
sandbox_id: kiloclaw_instances.sandbox_id,
})
.from(kiloclaw_instances)
.where(eq(kiloclaw_instances.id, instanceId))
.limit(1);
return row ? workerInstanceId(row) : undefined;
}

type EnsureActiveInstanceOpts = {
/** Organization ID. When provided, creates an org-owned instance. */
orgId?: string;
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/lib/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,31 @@ describe('User', () => {
).toBe(0);
});

it('should clear kiloclaw_cli_runs initiated by the deleted admin', async () => {
const admin = await insertTestUser({ is_admin: true });
const user = await insertTestUser();

const [run] = await db
.insert(kiloclaw_cli_runs)
.values({
user_id: user.id,
initiated_by_admin_id: admin.id,
prompt: 'admin run',
status: 'completed',
})
.returning({ id: kiloclaw_cli_runs.id });

await softDeleteUser(admin.id);

expect(
await db
.select({ initiated_by_admin_id: kiloclaw_cli_runs.initiated_by_admin_id })
.from(kiloclaw_cli_runs)
.where(eq(kiloclaw_cli_runs.id, run.id))
.then(r => r[0].initiated_by_admin_id)
).toBeNull();
});

it('should delete kiloclaw_subscriptions for the user', async () => {
const user = await insertTestUser();

Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,10 @@ export async function softDeleteUser(userId: string) {
.delete(kiloclaw_earlybird_purchases)
.where(eq(kiloclaw_earlybird_purchases.user_id, userId));
await tx.delete(kiloclaw_email_log).where(eq(kiloclaw_email_log.user_id, userId));
await tx
.update(kiloclaw_cli_runs)
.set({ initiated_by_admin_id: null })
.where(eq(kiloclaw_cli_runs.initiated_by_admin_id, userId));
await tx.delete(kiloclaw_cli_runs).where(eq(kiloclaw_cli_runs.user_id, userId));
await tx.delete(kiloclaw_instances).where(eq(kiloclaw_instances.user_id, userId));
await tx.delete(user_push_tokens).where(eq(user_push_tokens.user_id, userId));
Expand Down
180 changes: 179 additions & 1 deletion apps/web/src/routers/kiloclaw-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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 ||= 'http://localhost:8795';
process.env.KILOCLAW_INTERNAL_API_SECRET ||= 'test-secret';

import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { cleanupDbForTest } from '@/lib/drizzle';
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_cli_runs,
kiloclaw_earlybird_purchases,
kiloclaw_instances,
} from '@kilocode/db/schema';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyMock = jest.Mock<(...args: any[]) => any>;

type KiloClawClientMock = {
KiloClawInternalClient: AnyMock;
__getStatusMock: AnyMock;
__cancelKiloCliRunMock: AnyMock;
};

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

jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
const getStatusMock = jest.fn();
const cancelKiloCliRunMock = jest.fn();
return {
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
getStatus: getStatusMock,
cancelKiloCliRun: cancelKiloCliRunMock,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This mock no longer matches the helper's cancel path.

cancelCliRun() now calls client.getKiloCliRunStatus() before it attempts cancelKiloCliRun(). The mock instance only exposes getStatus and cancelKiloCliRun, so if the Jest module mock is applied transitively this test will throw client.getKiloCliRunStatus is not a function instead of exercising the destroyed-instance cancel flow.

})),
KiloClawApiError: class KiloClawApiError extends Error {
statusCode: number;
Expand All @@ -70,6 +80,7 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
}
},
__getStatusMock: getStatusMock,
__cancelKiloCliRunMock: cancelKiloCliRunMock,
};
});

Expand All @@ -78,6 +89,18 @@ const kiloclawClientMock = jest.requireMock<KiloClawClientMock>(
'@/lib/kiloclaw/kiloclaw-internal-client'
);

function sandboxId(): string {
return `ki_${crypto.randomUUID().replace(/-/g, '')}`;
}

async function grantKiloClawAccess(userId: string): Promise<void> {
await db.insert(kiloclaw_earlybird_purchases).values({
user_id: userId,
manual_payment_id: `manual-${crypto.randomUUID()}`,
amount_cents: 20_000,
});
}

describe('kiloclawRouter getStatus', () => {
beforeEach(async () => {
await cleanupDbForTest();
Expand Down Expand Up @@ -128,3 +151,158 @@ describe('kiloclawRouter getStatus', () => {
});
});
});

describe('kiloclawRouter listKiloCliRuns', () => {
beforeEach(async () => {
await cleanupDbForTest();
});

it('returns only runs for the active personal instance when one exists', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-cli-runs-${Math.random()}@example.com`,
});

await grantKiloClawAccess(user.id);

const [destroyedInstance, activeInstance] = await db
.insert(kiloclaw_instances)
.values([
{
user_id: user.id,
sandbox_id: sandboxId(),
destroyed_at: '2026-04-01T00:00:00.000Z',
},
{
user_id: user.id,
sandbox_id: sandboxId(),
},
])
.returning({ id: kiloclaw_instances.id });

if (!destroyedInstance || !activeInstance) {
throw new Error('Failed to create KiloClaw test instances');
}

await db.insert(kiloclaw_cli_runs).values([
{
user_id: user.id,
instance_id: destroyedInstance.id,
prompt: 'destroyed instance run',
status: 'completed',
started_at: '2026-04-01T00:00:00.000Z',
},
{
user_id: user.id,
instance_id: activeInstance.id,
prompt: 'active instance run',
status: 'completed',
started_at: '2026-04-03T00:00:00.000Z',
},
{
user_id: user.id,
instance_id: null,
prompt: 'legacy null instance run',
status: 'completed',
started_at: '2026-04-05T00:00:00.000Z',
},
]);

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

expect(result.runs).toHaveLength(1);
expect(result.runs[0]?.prompt).toBe('active instance run');
expect(result.runs[0]?.instance_id).toBe(activeInstance.id);
});

it('preserves user-scoped listing when no active personal instance exists', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-cli-runs-legacy-${Math.random()}@example.com`,
});

await grantKiloClawAccess(user.id);

const [destroyedInstance] = await db
.insert(kiloclaw_instances)
.values({
user_id: user.id,
sandbox_id: sandboxId(),
destroyed_at: '2026-04-01T00:00:00.000Z',
})
.returning({ id: kiloclaw_instances.id });

if (!destroyedInstance) {
throw new Error('Failed to create KiloClaw test instance');
}

await db.insert(kiloclaw_cli_runs).values([
{
user_id: user.id,
instance_id: destroyedInstance.id,
prompt: 'destroyed instance run',
status: 'completed',
started_at: '2026-04-01T00:00:00.000Z',
},
{
user_id: user.id,
instance_id: null,
prompt: 'legacy null instance run',
status: 'completed',
started_at: '2026-04-02T00:00:00.000Z',
},
]);

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

expect(result.runs.map(run => run.prompt)).toEqual([
'legacy null instance run',
'destroyed instance run',
]);
});

it('opens listed destroyed-instance runs when no active personal instance exists', async () => {
const user = await insertTestUser({
google_user_email: `kiloclaw-cli-runs-status-${Math.random()}@example.com`,
});

await grantKiloClawAccess(user.id);

const [destroyedInstance] = await db
.insert(kiloclaw_instances)
.values({
user_id: user.id,
sandbox_id: sandboxId(),
destroyed_at: '2026-04-01T00:00:00.000Z',
})
.returning({ id: kiloclaw_instances.id });

if (!destroyedInstance) {
throw new Error('Failed to create KiloClaw test instance');
}

const [run] = await db
.insert(kiloclaw_cli_runs)
.values({
user_id: user.id,
instance_id: destroyedInstance.id,
prompt: 'destroyed instance run',
status: 'completed',
started_at: '2026-04-01T00:00:00.000Z',
})
.returning({ id: kiloclaw_cli_runs.id });

if (!run) {
throw new Error('Failed to create KiloClaw CLI run');
}

const caller = createCaller({ user });
const result = await caller.getKiloCliRunStatus({ runId: run.id });

expect(result).toMatchObject({
hasRun: true,
status: 'completed',
prompt: 'destroyed instance run',
});
});
});
Loading