Skip to content
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;
}
32 changes: 31 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
19 changes: 10 additions & 9 deletions services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
KiloclawStopReason,
} from '@kilocode/worker-utils';
import {
imageRolloutSubjectFromSandboxId,
isInstanceKeyedSandboxId,
instanceIdFromSandboxId,
} from '@kilocode/worker-utils/instance-id';
Expand Down Expand Up @@ -245,7 +246,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
private async resolveImageStateForPin(
pinnedImageTag: string | null,
userId: string,
instanceId: string,
rolloutSubject: string,
opts: { isNew: boolean; ignoreCurrentImageTag?: boolean }
): Promise<void> {
if (pinnedImageTag) {
Expand Down Expand Up @@ -332,7 +333,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
const selected = await selectImageVersionForInstance({
kv: this.env.KV_CLAW_CACHE,
variant,
instanceId,
rolloutSubject,
currentImageTag: selectorCurrentImageTag,
autoEnroll,
});
Expand Down Expand Up @@ -1933,8 +1934,8 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
throw Object.assign(new Error('Cannot apply pin: instance has no userId'), { status: 404 });
}

const resolvedInstanceId = instanceId ?? this.s.userId;
await this.resolveImageStateForPin(imageTag, this.s.userId, resolvedInstanceId, {
const rolloutSubject = instanceId ?? this.s.userId;
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.

Must-fix: applyPinnedVersion(null, instanceId) always uses the DB row id as the rollout subject because the platform route passes instanceId into this RPC. For legacy user-keyed instances, restartMachine({ imageTag: 'latest' }) buckets by imageRolloutSubjectFromSandboxId(this.s.sandboxId, this.s.userId), so clearing a pin can write a different trackedImageTag than the actual upgrade path and leave DB/DO state inconsistent when there is no follow-up restart or that restart fails. Derive the rollout subject from loaded DO state here, for example imageRolloutSubjectFromSandboxId(this.s.sandboxId, this.s.userId), and add a legacy sandbox regression test for clearing a pin.

await this.resolveImageStateForPin(imageTag, this.s.userId, rolloutSubject, {
isNew: false,
// When clearing a pin (imageTag === null), force a fresh rollout
// decision instead of preserving the currently-tracked tag. Without
Expand Down Expand Up @@ -4066,10 +4067,10 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
if (options?.imageTag) {
if (options.imageTag === 'latest') {
const variant: ImageVariant = 'default';
const instanceIdForBucket =
this.s.sandboxId && isInstanceKeyedSandboxId(this.s.sandboxId)
? instanceIdFromSandboxId(this.s.sandboxId)
: (this.s.userId ?? '');
const rolloutSubject = imageRolloutSubjectFromSandboxId(
this.s.sandboxId,
this.s.userId ?? ''
);
let autoEnroll = false;
if (this.s.userId && this.env.HYPERDRIVE?.connectionString) {
try {
Expand All @@ -4086,7 +4087,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
const latest = await selectImageVersionForInstance({
kv: this.env.KV_CLAW_CACHE,
variant,
instanceId: instanceIdForBucket,
rolloutSubject,
currentImageTag: this.s.trackedImageTag,
autoEnroll,
});
Expand Down
Loading