Skip to content

Commit 57bf8a8

Browse files
fix(kiloclaw): derive upgrade banner rollout subject (#3603)
* fix(kiloclaw): align upgrade banner rollout subject * refactor(kiloclaw): share image rollout subject helper * refactor(kiloclaw): clarify latest version subject API * fix(kiloclaw): scope upgrade banner rollout lookup * fix(kiloclaw): derive rollout subject server-side * test(kiloclaw): cover anonymous latest version lookup * test(kiloclaw): cover latest version fallback paths * refactor(kiloclaw): rename rollout selector subject * fix(kiloclaw): align pin-clear rollout subject * fix(kiloclaw): remove redundant pin version parameter --------- Co-authored-by: Florian Hines <florian@kilocode.ai>
1 parent 5d10b3a commit 57bf8a8

14 files changed

Lines changed: 437 additions & 91 deletions

File tree

apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,19 +152,22 @@ export class KiloClawInternalClient {
152152
return this.request('/api/platform/versions');
153153
}
154154

155-
async getLatestVersion(opts?: {
156-
instanceId?: string;
155+
async getLatestVersion(): Promise<ImageVersionEntry | null> {
156+
return this.requestLatestVersion('/api/platform/versions/latest');
157+
}
158+
159+
async getLatestVersionForInstance(opts: {
160+
instanceId: string;
157161
currentImageTag?: string | null;
158162
}): Promise<ImageVersionEntry | null> {
159-
// Note: Early Access is resolved server-side from the instance's owning
160-
// user — callers do NOT pass it as a param. Trying to set it here would
161-
// be ignored.
162-
let path = '/api/platform/versions/latest';
163-
if (opts?.instanceId) {
164-
const params = new URLSearchParams({ instanceId: opts.instanceId });
165-
if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag);
166-
path += `?${params.toString()}`;
167-
}
163+
const params = new URLSearchParams({
164+
instanceId: opts.instanceId,
165+
});
166+
if (opts.currentImageTag) params.set('currentImageTag', opts.currentImageTag);
167+
return this.requestLatestVersion(`/api/platform/versions/latest?${params.toString()}`);
168+
}
169+
170+
private async requestLatestVersion(path: string): Promise<ImageVersionEntry | null> {
168171
try {
169172
return await this.request(path);
170173
} catch (err) {

apps/web/src/routers/kiloclaw-router.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>;
4343
type KiloClawClientMock = {
4444
KiloClawInternalClient: AnyMock;
4545
__getStatusMock: AnyMock;
46+
__getLatestVersionMock: AnyMock;
47+
__getLatestVersionForInstanceMock: AnyMock;
4648
__destroyMock: AnyMock;
4749
__startMock: AnyMock;
4850
};
@@ -109,11 +111,15 @@ jest.mock('@/lib/config.server', () => {
109111

110112
jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
111113
const getStatusMock = jest.fn();
114+
const getLatestVersionMock = jest.fn();
115+
const getLatestVersionForInstanceMock = jest.fn();
112116
const destroyMock = jest.fn();
113117
const startMock = jest.fn();
114118
return {
115119
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
116120
getStatus: getStatusMock,
121+
getLatestVersion: getLatestVersionMock,
122+
getLatestVersionForInstance: getLatestVersionForInstanceMock,
117123
start: startMock,
118124
destroy: destroyMock,
119125
})),
@@ -127,13 +133,16 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
127133
}
128134
},
129135
__getStatusMock: getStatusMock,
136+
__getLatestVersionMock: getLatestVersionMock,
137+
__getLatestVersionForInstanceMock: getLatestVersionForInstanceMock,
130138
__destroyMock: destroyMock,
131139
__startMock: startMock,
132140
};
133141
});
134142

135143
let createCaller: (ctx: { user: Awaited<ReturnType<typeof insertTestUser>> }) => {
136144
getStatus: () => Promise<unknown>;
145+
latestVersion: (input?: { currentImageTag?: string }) => Promise<unknown>;
137146
getNavState: () => Promise<{ hasActiveInstance: boolean }>;
138147
validateWeatherLocation: (input: { location: string }) => Promise<{
139148
location: string;
@@ -490,6 +499,55 @@ describe('kiloclawRouter getStatus', () => {
490499
});
491500
});
492501

502+
describe('kiloclawRouter latestVersion', () => {
503+
beforeEach(async () => {
504+
await cleanupDbForTest();
505+
kiloclawClientMock.KiloClawInternalClient.mockClear();
506+
kiloclawClientMock.__getLatestVersionMock.mockReset();
507+
kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset();
508+
});
509+
510+
it('passes the active instance row for server-derived rollout lookup', async () => {
511+
kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({
512+
imageTag: 'candidate-tag',
513+
});
514+
const user = await insertTestUser({
515+
google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
516+
});
517+
const instanceId = crypto.randomUUID();
518+
await db.insert(kiloclaw_instances).values({
519+
id: instanceId,
520+
user_id: user.id,
521+
sandbox_id: `ki_${instanceId.replace(/-/g, '')}`,
522+
});
523+
524+
const caller = createCaller({ user });
525+
await caller.latestVersion({ currentImageTag: 'current-tag' });
526+
527+
expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({
528+
instanceId,
529+
currentImageTag: 'current-tag',
530+
});
531+
expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled();
532+
});
533+
534+
it('uses anonymous latest version lookup when the user has no active instance', async () => {
535+
kiloclawClientMock.__getLatestVersionMock.mockResolvedValue({
536+
imageTag: 'anonymous-tag',
537+
});
538+
const user = await insertTestUser({
539+
google_user_email: `kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
540+
});
541+
542+
const caller = createCaller({ user });
543+
const result = await caller.latestVersion({ currentImageTag: 'current-tag' });
544+
545+
expect(result).toEqual({ imageTag: 'anonymous-tag' });
546+
expect(kiloclawClientMock.__getLatestVersionMock).toHaveBeenCalledWith();
547+
expect(kiloclawClientMock.__getLatestVersionForInstanceMock).not.toHaveBeenCalled();
548+
});
549+
});
550+
493551
describe('kiloclawRouter getNavState', () => {
494552
beforeEach(async () => {
495553
await cleanupDbForTest();

apps/web/src/routers/kiloclaw-router.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2857,25 +2857,11 @@ export const kiloclawRouter = createTRPCRouter({
28572857
latestVersion: baseProcedure
28582858
.input(z.object({ currentImageTag: z.string().min(1).optional() }).optional())
28592859
.query(async ({ ctx, input }) => {
2860-
// Pass instance + currentImageTag through; Early Access is resolved
2861-
// server-side from the instance's owning user (the platform endpoint
2862-
// does the kilocode_users lookup itself, so callers can't fake it).
2863-
const [instance] = await db
2864-
.select({ id: kiloclaw_instances.id })
2865-
.from(kiloclaw_instances)
2866-
.where(
2867-
and(
2868-
eq(kiloclaw_instances.user_id, ctx.user.id),
2869-
isNull(kiloclaw_instances.organization_id),
2870-
isNull(kiloclaw_instances.destroyed_at)
2871-
)
2872-
)
2873-
.limit(1);
2874-
2860+
const instance = await getActiveInstance(ctx.user.id);
28752861
const client = new KiloClawInternalClient();
28762862
if (!instance) return client.getLatestVersion();
28772863

2878-
return client.getLatestVersion({
2864+
return client.getLatestVersionForInstance({
28792865
instanceId: instance.id,
28802866
currentImageTag: input?.currentImageTag ?? null,
28812867
});

apps/web/src/routers/organizations/organization-kiloclaw-router.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type AnyMock = jest.Mock<(...args: any[]) => any>;
2626

2727
type KiloClawClientMock = {
2828
__destroyMock: AnyMock;
29+
__getLatestVersionMock: AnyMock;
30+
__getLatestVersionForInstanceMock: AnyMock;
2931
__patchWebSearchConfigMock: AnyMock;
3032
__provisionMock: AnyMock;
3133
__restartGatewayProcessMock: AnyMock;
@@ -73,6 +75,8 @@ jest.mock('@/lib/config.server', () => {
7375

7476
jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
7577
const destroyMock = jest.fn();
78+
const getLatestVersionMock = jest.fn();
79+
const getLatestVersionForInstanceMock = jest.fn();
7680
const patchWebSearchConfigMock = jest.fn();
7781
const provisionMock = jest.fn();
7882
const restartGatewayProcessMock = jest.fn();
@@ -82,6 +86,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
8286
return {
8387
KiloClawInternalClient: jest.fn().mockImplementation(() => ({
8488
destroy: destroyMock,
89+
getLatestVersion: getLatestVersionMock,
90+
getLatestVersionForInstance: getLatestVersionForInstanceMock,
8591
patchWebSearchConfig: patchWebSearchConfigMock,
8692
provision: provisionMock,
8793
restartGatewayProcess: restartGatewayProcessMock,
@@ -99,6 +105,8 @@ jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
99105
}
100106
},
101107
__destroyMock: destroyMock,
108+
__getLatestVersionMock: getLatestVersionMock,
109+
__getLatestVersionForInstanceMock: getLatestVersionForInstanceMock,
102110
__patchWebSearchConfigMock: patchWebSearchConfigMock,
103111
__provisionMock: provisionMock,
104112
__restartGatewayProcessMock: restartGatewayProcessMock,
@@ -169,6 +177,37 @@ async function addOrganizationSeatEntitlement(organizationId: string): Promise<v
169177
});
170178
}
171179

180+
describe('organizations.kiloclaw.latestVersion', () => {
181+
beforeEach(async () => {
182+
await cleanupDbForTest();
183+
kiloclawClientMock.__getLatestVersionMock.mockReset();
184+
kiloclawClientMock.__getLatestVersionForInstanceMock.mockReset();
185+
});
186+
187+
it('passes the active org instance row for server-derived rollout lookup', async () => {
188+
kiloclawClientMock.__getLatestVersionForInstanceMock.mockResolvedValue({
189+
imageTag: 'candidate-tag',
190+
});
191+
const user = await insertTestUser({
192+
google_user_email: `org-kiloclaw-latest-version-${crypto.randomUUID()}@example.com`,
193+
});
194+
const organization = await createOrganization('Org Latest Version Test', user.id);
195+
const instanceId = await createActiveOrgInstance(user.id, organization.id);
196+
197+
const caller = await createCallerForUser(user.id);
198+
await caller.organizations.kiloclaw.latestVersion({
199+
organizationId: organization.id,
200+
currentImageTag: 'current-tag',
201+
});
202+
203+
expect(kiloclawClientMock.__getLatestVersionForInstanceMock).toHaveBeenCalledWith({
204+
instanceId,
205+
currentImageTag: 'current-tag',
206+
});
207+
expect(kiloclawClientMock.__getLatestVersionMock).not.toHaveBeenCalled();
208+
});
209+
});
210+
172211
describe('organizations.kiloclaw.listActiveInstances', () => {
173212
beforeEach(async () => {
174213
await cleanupDbForTest();

apps/web/src/routers/organizations/organization-kiloclaw-router.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,7 @@ export const organizationKiloclawRouter = createTRPCRouter({
308308
const client = new KiloClawInternalClient();
309309
const instance = await getActiveOrgInstance(ctx.user.id, input.organizationId);
310310
if (!instance) return client.getLatestVersion();
311-
// Early Access is resolved server-side via the platform endpoint
312-
// (instance → owner → kiloclaw_early_access lookup), not passed by us.
313-
return client.getLatestVersion({
311+
return client.getLatestVersionForInstance({
314312
instanceId: instance.id,
315313
currentImageTag: input.currentImageTag ?? null,
316314
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { imageRolloutSubjectFromSandboxId, sandboxIdFromInstanceId } from './instance-id';
3+
4+
describe('imageRolloutSubjectFromSandboxId', () => {
5+
it('uses userId for legacy sandboxIds', () => {
6+
expect(imageRolloutSubjectFromSandboxId('dXNlci1sZWdhY3k', 'user-legacy')).toBe('user-legacy');
7+
});
8+
9+
it('decodes the rollout subject from ki_ sandboxIds', () => {
10+
const instanceId = '11111111-2222-4333-8444-555555555555';
11+
12+
expect(
13+
imageRolloutSubjectFromSandboxId(sandboxIdFromInstanceId(instanceId), 'user-instance-keyed')
14+
).toBe(instanceId);
15+
});
16+
17+
it('uses userId when sandboxId is absent', () => {
18+
expect(imageRolloutSubjectFromSandboxId(null, 'user-missing-sandbox')).toBe(
19+
'user-missing-sandbox'
20+
);
21+
});
22+
});

packages/worker-utils/src/instance-id.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,18 @@ export function instanceIdFromSandboxId(sandboxId: string): string {
5151
const hex = sandboxId.slice(3); // strip "ki_"
5252
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
5353
}
54+
55+
/**
56+
* Return the subject used to bucket image-version rollouts.
57+
*
58+
* Legacy rows are user-keyed, so they bucket by userId. Instance-keyed rows
59+
* bucket by the UUID encoded in the `ki_` sandboxId. This mirrors
60+
* KiloClawInstance.restartMachine({ imageTag: 'latest' }).
61+
*/
62+
export function imageRolloutSubjectFromSandboxId(
63+
sandboxId: string | null | undefined,
64+
userId: string
65+
): string {
66+
if (!sandboxId) return userId;
67+
return isInstanceKeyedSandboxId(sandboxId) ? instanceIdFromSandboxId(sandboxId) : userId;
68+
}

services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7821,12 +7821,42 @@ describe('restartMachine image tag override', () => {
78217821
const result = await instance.restartMachine({ imageTag: 'latest' });
78227822

78237823
expect(result.success).toBe(true);
7824-
expect(selectImageVersionForInstance).toHaveBeenCalledOnce();
7824+
expect(selectImageVersionForInstance).toHaveBeenCalledWith(
7825+
expect.objectContaining({ rolloutSubject: 'user-1' })
7826+
);
78257827
expect(storage._store.get('trackedImageTag')).toBe('new-tag-from-kv');
78267828
expect(storage._store.get('openclawVersion')).toBe('2.0.0');
78277829
expect(storage._store.get('imageVariant')).toBe('default');
78287830
});
78297831

7832+
it('resolves latest with the instance UUID for instance-keyed sandboxes', async () => {
7833+
const { instance, storage } = createInstance();
7834+
const instanceId = '123e4567-e89b-12d3-a456-426614174000';
7835+
await seedRunning(storage, {
7836+
sandboxId: 'ki_123e4567e89b12d3a456426614174000',
7837+
trackedImageTag: 'old-tag',
7838+
openclawVersion: '1.0.0',
7839+
imageVariant: 'default',
7840+
});
7841+
7842+
(selectImageVersionForInstance as Mock).mockResolvedValueOnce({
7843+
openclawVersion: '2.0.0',
7844+
variant: 'default',
7845+
imageTag: 'new-tag-from-kv',
7846+
imageDigest: null,
7847+
publishedAt: new Date().toISOString(),
7848+
rolloutPercent: 0,
7849+
isLatest: true,
7850+
});
7851+
7852+
const result = await instance.restartMachine({ imageTag: 'latest' });
7853+
7854+
expect(result.success).toBe(true);
7855+
expect(selectImageVersionForInstance).toHaveBeenCalledWith(
7856+
expect.objectContaining({ rolloutSubject: instanceId })
7857+
);
7858+
});
7859+
78307860
it('falls back gracefully when "latest" but selector returns null', async () => {
78317861
const { instance, storage } = createInstance();
78327862
await seedRunning(storage, { trackedImageTag: 'old-tag' });
@@ -8007,6 +8037,36 @@ describe('applyPinnedVersion', () => {
80078037
);
80088038
});
80098039

8040+
it('when cleared through an instance-id-aware route, uses legacy sandbox rollout subject for legacy instances', async () => {
8041+
const { instance, storage } = createInstance();
8042+
await seedRunning(storage, {
8043+
sandboxId: 'sandbox-1',
8044+
trackedImageTag: 'candidate-tag',
8045+
openclawVersion: '2026.4.9',
8046+
imageVariant: 'default',
8047+
});
8048+
8049+
(selectImageVersionForInstance as Mock).mockResolvedValueOnce({
8050+
openclawVersion: '2026.4.23',
8051+
variant: 'default',
8052+
imageTag: 'latest-tag',
8053+
imageDigest: 'sha256:latest',
8054+
publishedAt: new Date().toISOString(),
8055+
rolloutPercent: 100,
8056+
isLatest: true,
8057+
});
8058+
8059+
await instance.applyPinnedVersion(null);
8060+
8061+
expect(selectImageVersionForInstance).toHaveBeenCalledWith(
8062+
expect.objectContaining({
8063+
rolloutSubject: 'user-1',
8064+
currentImageTag: null,
8065+
})
8066+
);
8067+
expect(storage._store.get('trackedImageTag')).toBe('latest-tag');
8068+
});
8069+
80108070
it('when cleared and no rollout target, leaves existing tracked image alone', async () => {
80118071
const { instance, storage } = createInstance();
80128072
await seedRunning(storage, {

0 commit comments

Comments
 (0)