Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
25 changes: 14 additions & 11 deletions apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,19 +152,22 @@ export class KiloClawInternalClient {
return this.request('/api/platform/versions');
}

async getLatestVersion(opts?: {
instanceId?: string;
async getLatestVersion(): Promise<ImageVersionEntry | null> {
return this.requestLatestVersion('/api/platform/versions/latest');
}

async getLatestVersionForInstance(opts: {
instanceId: string;
currentImageTag?: string | null;
}): Promise<ImageVersionEntry | null> {
// Note: Early Access is resolved server-side from the instance's owning
// user — callers do NOT pass it as a param. Trying to set it here would
// be ignored.
let path = '/api/platform/versions/latest';
if (opts?.instanceId) {
const params = new URLSearchParams({ instanceId: opts.instanceId });
if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag);
path += `?${params.toString()}`;
}
const params = new URLSearchParams({
instanceId: opts.instanceId,
});
if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag);
return this.requestLatestVersion(`/api/platform/versions/latest?${params.toString()}`);
}

private async requestLatestVersion(path: string): Promise<ImageVersionEntry | null> {
try {
return await this.request(path);
} catch (err) {
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/routers/kiloclaw-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>;
type KiloClawClientMock = {
KiloClawInternalClient: AnyMock;
__getStatusMock: AnyMock;
__getLatestVersionMock: AnyMock;
__getLatestVersionForInstanceMock: AnyMock;
__destroyMock: AnyMock;
__startMock: AnyMock;
};
Expand Down Expand Up @@ -109,11 +111,15 @@ jest.mock('@/lib/config.server', () => {

jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
const getStatusMock = jest.fn();
const getLatestVersionMock = jest.fn();
const getLatestVersionForInstanceMock = jest.fn();
const destroyMock = jest.fn();
const startMock = jest.fn();
return {
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
getStatus: getStatusMock,
getLatestVersion: getLatestVersionMock,
getLatestVersionForInstance: getLatestVersionForInstanceMock,
start: startMock,
destroy: destroyMock,
})),
Expand All @@ -127,13 +133,16 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
}
},
__getStatusMock: getStatusMock,
__getLatestVersionMock: getLatestVersionMock,
__getLatestVersionForInstanceMock: getLatestVersionForInstanceMock,
__destroyMock: destroyMock,
__startMock: startMock,
};
});

let createCaller: (ctx: { user: Awaited<ReturnType<typeof insertTestUser>> }) => {
getStatus: () => Promise<unknown>;
latestVersion: (input?: { currentImageTag?: string }) => Promise<unknown>;
validateWeatherLocation: (input: { location: string }) => Promise<{
location: string;
currentWeatherText: string;
Expand Down Expand Up @@ -489,6 +498,55 @@ describe('kiloclawRouter getStatus', () => {
});
});

describe('kiloclawRouter latestVersion', () => {
beforeEach(async () => {
await cleanupDbForTest();
kiloclawClientMock.KiloClawInternalClient.mockClear();
kiloclawClientMock.__getLatestVersionMock.mockReset();
kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset();
});

it('passes the active instance row for server-derived rollout lookup', async () => {
kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({
imageTag: 'candidate-tag',
});
const user = await insertTestUser({
google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
});
const instanceId = crypto.randomUUID();
await db.insert(kiloclaw_instances).values({
id: instanceId,
user_id: user.id,
sandbox_id: `ki_${instanceId.replace(/-/g, '')}`,
});

const caller = createCaller({ user });
await caller.latestVersion({ currentImageTag: 'current-tag' });

expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({
instanceId,
currentImageTag: 'current-tag',
});
expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled();
});

it('uses anonymous latest version lookup when the user has no active instance', async () => {
kiloclawClientMock.__getLatestVersionMock.mockResolvedValue({
imageTag: 'anonymous-tag',
});
const user = await insertTestUser({
google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
});

const caller = createCaller({ user });
const result = await caller.latestVersion({ currentImageTag: 'current-tag' });

expect(result).toEqual({ imageTag: 'anonymous-tag' });
expect(kiloclawClientMock.__getLatestVersionMock).toHaveBeenCalledWith();
expect(kiloclawClientMock.__getLatestVersionForInstanceMock).not.toHaveBeenCalled();
});
});

describe('kiloclawRouter start', () => {
beforeEach(async () => {
await cleanupDbForTest();
Expand Down
18 changes: 2 additions & 16 deletions apps/web/src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2857,25 +2857,11 @@ export const kiloclawRouter = createTRPCRouter({
latestVersion: baseProcedure
.input(z.object({ currentImageTag: z.string().min(1).optional() }).optional())
.query(async ({ ctx, input }) => {
// Pass instance + currentImageTag through; Early Access is resolved
// server-side from the instance's owning user (the platform endpoint
// does the kilocode_users lookup itself, so callers can't fake it).
const [instance] = await db
.select({ id: kiloclaw_instances.id })
.from(kiloclaw_instances)
.where(
and(
eq(kiloclaw_instances.user_id, ctx.user.id),
isNull(kiloclaw_instances.organization_id),
isNull(kiloclaw_instances.destroyed_at)
)
)
.limit(1);

const instance = await getActiveInstance(ctx.user.id);
const client = new KiloClawInternalClient();
if (!instance) return client.getLatestVersion();

return client.getLatestVersion({
return client.getLatestVersionForInstance({
instanceId: instance.id,
currentImageTag: input?.currentImageTag ?? null,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>;

type KiloClawClientMock = {
__destroyMock: AnyMock;
__getLatestVersionMock: AnyMock;
__getLatestVersionForInstanceMock: AnyMock;
__patchWebSearchConfigMock: AnyMock;
__provisionMock: AnyMock;
__restartGatewayProcessMock: AnyMock;
Expand Down Expand Up @@ -73,6 +75,8 @@ jest.mock('@/lib/config.server', () => {

jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
const destroyMock = jest.fn();
const getLatestVersionMock = jest.fn();
const getLatestVersionForInstanceMock = jest.fn();
const patchWebSearchConfigMock = jest.fn();
const provisionMock = jest.fn();
const restartGatewayProcessMock = jest.fn();
Expand All @@ -82,6 +86,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
return {
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
destroy: destroyMock,
getLatestVersion: getLatestVersionMock,
getLatestVersionForInstance: getLatestVersionForInstanceMock,
patchWebSearchConfig: patchWebSearchConfigMock,
provision: provisionMock,
restartGatewayProcess: restartGatewayProcessMock,
Expand All @@ -99,6 +105,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
}
},
__destroyMock: destroyMock,
__getLatestVersionMock: getLatestVersionMock,
__getLatestVersionForInstanceMock: getLatestVersionForInstanceMock,
__patchWebSearchConfigMock: patchWebSearchConfigMock,
__provisionMock: provisionMock,
__restartGatewayProcessMock: restartGatewayProcessMock,
Expand Down Expand Up @@ -169,6 +177,37 @@ async function addOrganizationSeatEntitlement(organizationId: string): Promise<v
});
}

describe('organizations.kiloclaw.latestVersion', () => {
beforeEach(async () => {
await cleanupDbForTest();
kiloclawClientMock.__getLatestVersionMock.mockReset();
kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset();
});

it('passes the active org instance row for server-derived rollout lookup', async () => {
kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({
imageTag: 'candidate-tag',
});
const user = await insertTestUser({
google_user_email: `org-kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
});
const organization = await createOrganization('Org Latest Version Test', user.id);
const instanceId = await createActiveOrgInstance(user.id, organization.id);

const caller = await createCallerForUser(user.id);
await caller.organizations.kiloclaw.latestVersion({
organizationId: organization.id,
currentImageTag: 'current-tag',
});

expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({
instanceId,
currentImageTag: 'current-tag',
});
expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled();
});
});

describe('organizations.kiloclaw.listActiveInstances', () => {
beforeEach(async () => {
await cleanupDbForTest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,7 @@ export const organizationKiloclawRouter = createTRPCRouter({
const client = new KiloClawInternalClient();
const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId);
if (!instance) return client.getLatestVersion();
// Early Access is resolved server-side via the platform endpoint
// (instance → owner → kiloclaw_early_access lookup), not passed by us.
return client.getLatestVersion({
return client.getLatestVersionForInstance({
instanceId: instance.id,
currentImageTag: input.currentImageTag ?? null,
});
Expand Down
22 changes: 22 additions & 0 deletions packages/worker-utils/src/instance-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { imageRolloutSubjectFromSandboxId, sandboxIdFromInstanceId } from './instance-id';

describe('imageRolloutSubjectFromSandboxId', () => {
it('uses userId for legacy sandboxIds', () => {
expect(imageRolloutSubjectFromSandboxId('dXNlci1sZWdhY3k', 'user-legacy')).toBe('user-legacy');
});

it('decodes the rollout subject from ki_ sandboxIds', () => {
const instanceId = '11111111-2222-4333-8444-555555555555';

expect(
imageRolloutSubjectFromSandboxId(sandboxIdFromInstanceId(instanceId), 'user-instance-keyed')
).toBe(instanceId);
});

it('uses userId when sandboxId is absent', () => {
expect(imageRolloutSubjectFromSandboxId(null, 'user-missing-sandbox')).toBe(
'user-missing-sandbox'
);
});
});
15 changes: 15 additions & 0 deletions packages/worker-utils/src/instance-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,18 @@ export function instanceIdFromSandboxId(sandboxId: string): string {
const hex = sandboxId.slice(3); // strip "ki_"
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

/**
* Return the subject used to bucket image-version rollouts.
*
* Legacy rows are user-keyed, so they bucket by userId. Instance-keyed rows
* bucket by the UUID encoded in the `ki_` sandboxId. This mirrors
* KiloClawInstance.restartMachine({ imageTag: 'latest' }).
*/
export function imageRolloutSubjectFromSandboxId(
sandboxId: string | null | undefined,
userId: string
): string {
if (!sandboxId) return userId;
return isInstanceKeyedSandboxId(sandboxId) ? instanceIdFromSandboxId(sandboxId) : userId;
}
62 changes: 61 additions & 1 deletion services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7821,12 +7821,42 @@ describe('restartMachine image tag override', () => {
const result = await instance.restartMachine({ imageTag: 'latest' });

expect(result.success).toBe(true);
expect(selectImageVersionForInstance).toHaveBeenCalledOnce();
expect(selectImageVersionForInstance).toHaveBeenCalledWith(
expect.objectContaining({ rolloutSubject: 'user-1' })
);
expect(storage._store.get('trackedImageTag')).toBe('new-tag-from-kv');
expect(storage._store.get('openclawVersion')).toBe('2.0.0');
expect(storage._store.get('imageVariant')).toBe('default');
});

it('resolves latest with the instance UUID for instance-keyed sandboxes', async () => {
const { instance, storage } = createInstance();
const instanceId = '123e4567-e89b-12d3-a456-426614174000';
await seedRunning(storage, {
sandboxId: 'ki_123e4567e89b12d3a456426614174000',
trackedImageTag: 'old-tag',
openclawVersion: '1.0.0',
imageVariant: 'default',
});

(selectImageVersionForInstance as Mock).mockResolvedValueOnce({
openclawVersion: '2.0.0',
variant: 'default',
imageTag: 'new-tag-from-kv',
imageDigest: null,
publishedAt: new Date().toISOString(),
rolloutPercent: 0,
isLatest: true,
});

const result = await instance.restartMachine({ imageTag: 'latest' });

expect(result.success).toBe(true);
expect(selectImageVersionForInstance).toHaveBeenCalledWith(
expect.objectContaining({ rolloutSubject: instanceId })
);
});

it('falls back gracefully when "latest" but selector returns null', async () => {
const { instance, storage } = createInstance();
await seedRunning(storage, { trackedImageTag: 'old-tag' });
Expand Down Expand Up @@ -8007,6 +8037,36 @@ describe('applyPinnedVersion', () => {
);
});

it('when cleared through an instance-id-aware route, uses legacy sandbox rollout subject for legacy instances', async () => {
const { instance, storage } = createInstance();
await seedRunning(storage, {
sandboxId: 'sandbox-1',
trackedImageTag: 'candidate-tag',
openclawVersion: '2026.4.9',
imageVariant: 'default',
});

(selectImageVersionForInstance as Mock).mockResolvedValueOnce({
openclawVersion: '2026.4.23',
variant: 'default',
imageTag: 'latest-tag',
imageDigest: 'sha256:latest',
publishedAt: new Date().toISOString(),
rolloutPercent: 100,
isLatest: true,
});

await instance.applyPinnedVersion(null, '123e4567-e89b-12d3-a456-426614174000');

expect(selectImageVersionForInstance).toHaveBeenCalledWith(
expect.objectContaining({
rolloutSubject: 'user-1',
currentImageTag: null,
})
);
expect(storage._store.get('trackedImageTag')).toBe('latest-tag');
});

it('when cleared and no rollout target, leaves existing tracked image alone', async () => {
const { instance, storage } = createInstance();
await seedRunning(storage, {
Expand Down
Loading