From b194b7f514738423b526cf65595462c7faa2f2fa Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 2 Mar 2026 16:16:11 -0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20KiloClaw=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?provision=20pin=20support=20and=20admin=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire pinnedImageTag through the full provision flow so pinned users get their specific image version instead of :latest. --- kiloclaw/Dockerfile | 2 +- kiloclaw/scripts/push-dev.sh | 24 ++++- .../src/durable-objects/kiloclaw-instance.ts | 85 ++++++++++++++--- kiloclaw/src/lib/catalog-registration.ts | 36 +++++++ kiloclaw/src/lib/image-version.ts | 33 +++++++ kiloclaw/src/routes/platform.ts | 17 +++- kiloclaw/src/schemas/instance-config.ts | 4 + .../withStatusQueryBoundary.test.ts | 4 + .../KiloclawInstanceDetail.tsx | 44 +++++++-- .../KiloclawVersions/KiloclawVersionsPage.tsx | 51 ++++++++-- src/lib/kiloclaw/kiloclaw-internal-client.ts | 13 +++ src/lib/kiloclaw/types.ts | 14 +++ src/routers/admin-kiloclaw-versions-router.ts | 94 ++++++++++++++++--- src/routers/kiloclaw-router.ts | 11 +++ 14 files changed, 382 insertions(+), 50 deletions(-) diff --git a/kiloclaw/Dockerfile b/kiloclaw/Dockerfile index 3874268a1..cd25026a0 100644 --- a/kiloclaw/Dockerfile +++ b/kiloclaw/Dockerfile @@ -74,7 +74,7 @@ RUN mkdir -p /root/.openclaw \ # Copy startup script # Build cache bust: 2026-02-27-v57-openclaw-2026.2.26-1password-arch-fix -RUN echo "7" +RUN echo "8" COPY start-openclaw.sh /usr/local/bin/start-openclaw.sh COPY openclaw-pairing-list.js /usr/local/bin/openclaw-pairing-list.js COPY openclaw-device-pairing-list.js /usr/local/bin/openclaw-device-pairing-list.js diff --git a/kiloclaw/scripts/push-dev.sh b/kiloclaw/scripts/push-dev.sh index c33f654b3..077ed73fe 100755 --- a/kiloclaw/scripts/push-dev.sh +++ b/kiloclaw/scripts/push-dev.sh @@ -26,14 +26,26 @@ GIT_SHA="$(git -C "$KILOCLAW_DIR" rev-parse HEAD 2>/dev/null || echo 'unknown')" echo "Building + pushing $IMAGE (linux/amd64) ..." echo "Controller commit: $GIT_SHA" + +# Use --metadata-file to capture the pushed image digest +METADATA_FILE="$(mktemp)" +trap 'rm -f "$METADATA_FILE"' EXIT + docker buildx build \ --platform linux/amd64 \ -f "$KILOCLAW_DIR/Dockerfile" \ --build-arg "CONTROLLER_COMMIT=$GIT_SHA" \ -t "$IMAGE" \ --push \ + --metadata-file "$METADATA_FILE" \ "$KILOCLAW_DIR" +# Extract digest from build metadata +DIGEST="" +if [ -f "$METADATA_FILE" ] && command -v jq >/dev/null 2>&1; then + DIGEST=$(jq -r '.["containerimage.digest"] // empty' "$METADATA_FILE" 2>/dev/null) +fi + # Update .dev.vars if [ -f "$KILOCLAW_DIR/.dev.vars" ]; then if grep -q '^FLY_IMAGE_TAG=' "$KILOCLAW_DIR/.dev.vars"; then @@ -41,7 +53,17 @@ if [ -f "$KILOCLAW_DIR/.dev.vars" ]; then else echo "FLY_IMAGE_TAG=$TAG" >> "$KILOCLAW_DIR/.dev.vars" fi - echo "Updated .dev.vars: FLY_IMAGE_TAG=$TAG" + + if [ -n "$DIGEST" ]; then + if grep -q '^FLY_IMAGE_DIGEST=' "$KILOCLAW_DIR/.dev.vars"; then + sed -i '' "s|^FLY_IMAGE_DIGEST=.*|FLY_IMAGE_DIGEST=$DIGEST|" "$KILOCLAW_DIR/.dev.vars" + else + echo "FLY_IMAGE_DIGEST=$DIGEST" >> "$KILOCLAW_DIR/.dev.vars" + fi + echo "Updated .dev.vars: FLY_IMAGE_TAG=$TAG FLY_IMAGE_DIGEST=$DIGEST" + else + echo "Updated .dev.vars: FLY_IMAGE_TAG=$TAG (digest not captured)" + fi else echo "No .dev.vars found — set FLY_IMAGE_TAG=$TAG manually" fi diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index b67cce51a..0eac3222d 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -62,7 +62,9 @@ import * as fly from '../fly/client'; import { appNameFromUserId } from '../fly/apps'; import { ENCRYPTED_ENV_PREFIX, encryptEnvValue } from '../utils/env-encryption'; import { z, type ZodType } from 'zod'; -import { resolveLatestVersion } from '../lib/image-version'; +import { resolveLatestVersion, resolveVersionByTag } from '../lib/image-version'; +import { lookupCatalogVersion } from '../lib/catalog-registration'; +import type { ImageVariant } from '../schemas/image-version'; type InstanceStatus = PersistedState['status']; @@ -381,6 +383,7 @@ export class KiloClawInstance extends DurableObject { private openclawVersion: string | null = null; private imageVariant: string | null = null; private trackedImageTag: string | null = null; + private trackedImageDigest: string | null = null; // In-memory only (not persisted to SQLite) — throttles live Fly checks in getStatus() private lastLiveCheckAt: number | null = null; @@ -421,6 +424,7 @@ export class KiloClawInstance extends DurableObject { this.openclawVersion = s.openclawVersion; this.imageVariant = s.imageVariant; this.trackedImageTag = s.trackedImageTag; + this.trackedImageDigest = s.trackedImageDigest; } else { const hasAnyData = entries.size > 0; if (hasAnyData) { @@ -487,18 +491,64 @@ export class KiloClawInstance extends DurableObject { console.log('[DO] Created Fly Volume:', volume.id, 'region:', volume.region); } - // Resolve the latest registered version on every provision (including re-provision). - // If the registry isn't populated yet, fields stay null → fallback to FLY_IMAGE_TAG. - const variant = 'default'; // hardcoded day 1; future: from config or provision request - const latest = await resolveLatestVersion(this.env.KV_CLAW_CACHE, variant); - if (latest) { - this.openclawVersion = latest.openclawVersion; - this.imageVariant = latest.variant; - this.trackedImageTag = latest.imageTag; - } else if (isNew) { - this.openclawVersion = null; - this.imageVariant = null; - this.trackedImageTag = null; + // Resolve the image version for this provision. + // If the user has a pinned image tag, look it up in KV first (fast), then Postgres (authoritative). + // If not pinned, resolve latest from KV. + console.log('[DO] provision: pinnedImageTag from config:', config.pinnedImageTag ?? 'none'); + if (config.pinnedImageTag) { + // Try KV first (fast, but only has versions registered by the current worker) + let pinned = await resolveVersionByTag(this.env.KV_CLAW_CACHE, config.pinnedImageTag); + + // Fall back to Postgres catalog (authoritative, has all synced versions) + if (!pinned && this.env.HYPERDRIVE?.connectionString) { + try { + const catalogEntry = await lookupCatalogVersion( + this.env.HYPERDRIVE.connectionString, + config.pinnedImageTag + ); + if (catalogEntry) { + pinned = { + openclawVersion: catalogEntry.openclawVersion, + variant: catalogEntry.variant as ImageVariant, + imageTag: catalogEntry.imageTag, + imageDigest: catalogEntry.imageDigest, + publishedAt: catalogEntry.publishedAt, + }; + console.log('[DO] Resolved pinned tag from Postgres catalog:', config.pinnedImageTag); + } + } catch (err) { + console.warn('[DO] Failed to look up pinned tag in Postgres:', err instanceof Error ? err.message : err); + } + } + + if (pinned) { + this.openclawVersion = pinned.openclawVersion; + this.imageVariant = pinned.variant; + this.trackedImageTag = pinned.imageTag; + this.trackedImageDigest = pinned.imageDigest; + console.log('[DO] Using pinned version:', pinned.openclawVersion, '→', pinned.imageTag); + } else { + // Pinned tag not found in KV or Postgres — use the tag directly but metadata is unknown. + console.warn('[DO] Pinned tag not found in KV or Postgres, using tag directly:', config.pinnedImageTag); + this.trackedImageTag = config.pinnedImageTag; + this.trackedImageDigest = null; + } + } else { + // No pin — resolve latest registered version. + // If the registry isn't populated yet, fields stay null → fallback to FLY_IMAGE_TAG. + const variant = 'default'; // hardcoded day 1; future: from config or provision request + const latest = await resolveLatestVersion(this.env.KV_CLAW_CACHE, variant); + if (latest) { + this.openclawVersion = latest.openclawVersion; + this.imageVariant = latest.variant; + this.trackedImageTag = latest.imageTag; + this.trackedImageDigest = latest.imageDigest; + } else if (isNew) { + this.openclawVersion = null; + this.imageVariant = null; + this.trackedImageTag = null; + this.trackedImageDigest = null; + } } const configFields = { @@ -518,6 +568,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: this.openclawVersion, imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, + trackedImageDigest: this.trackedImageDigest, }; const update = isNew @@ -1070,6 +1121,7 @@ export class KiloClawInstance extends DurableObject { const { envVars, minSecretsVersion } = await this.buildUserEnvVars(); const guest = guestFromSize(this.machineSize); const imageTag = this.resolveImageTag(); + console.log('[DO] startGateway: deploying with imageTag:', imageTag, 'trackedImageTag:', this.trackedImageTag, 'openclawVersion:', this.openclawVersion); const identity = { userId: this.userId, sandboxId: this.sandboxId, @@ -1253,6 +1305,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: string | null; imageVariant: string | null; trackedImageTag: string | null; + trackedImageDigest: string | null; }> { await this.loadState(); @@ -1287,6 +1340,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: this.openclawVersion, imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, + trackedImageDigest: this.trackedImageDigest, }; } @@ -1527,6 +1581,7 @@ export class KiloClawInstance extends DurableObject { this.openclawVersion = latest.openclawVersion; this.imageVariant = latest.variant; this.trackedImageTag = latest.imageTag; + this.trackedImageDigest = latest.imageDigest; } // If KV empty, fall through to existing resolveImageTag() fallback } else { @@ -1534,12 +1589,14 @@ export class KiloClawInstance extends DurableObject { this.trackedImageTag = options.imageTag; this.openclawVersion = null; this.imageVariant = null; + this.trackedImageDigest = null; } await this.ctx.storage.put( storageUpdate({ openclawVersion: this.openclawVersion, imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, + trackedImageDigest: this.trackedImageDigest, }) ); } @@ -2112,6 +2169,7 @@ export class KiloClawInstance extends DurableObject { this.openclawVersion = null; this.imageVariant = null; this.trackedImageTag = null; + this.trackedImageDigest = null; this.loaded = false; } @@ -2544,6 +2602,7 @@ export class KiloClawInstance extends DurableObject { this.openclawVersion = null; this.imageVariant = null; this.trackedImageTag = null; + this.trackedImageDigest = null; this.loaded = true; console.log('[DO] Restored from Postgres: sandboxId =', instance.sandboxId); diff --git a/kiloclaw/src/lib/catalog-registration.ts b/kiloclaw/src/lib/catalog-registration.ts index 102a643fb..f01bb5c6d 100644 --- a/kiloclaw/src/lib/catalog-registration.ts +++ b/kiloclaw/src/lib/catalog-registration.ts @@ -5,6 +5,7 @@ import { getWorkerDb } from '@kilocode/db/client'; import { kiloclaw_image_catalog, sql, ne } from '@kilocode/db'; +import { eq } from 'drizzle-orm'; import { isValidImageTag } from './image-tag-validation'; const OPENCLAW_VERSION_RE = /^\d{4}\.\d{1,2}\.\d{1,2}$/; @@ -46,6 +47,41 @@ function validateEntry(entry: CatalogVersionEntry): string | null { return null; } +/** + * Look up a catalog entry by image tag from Postgres via Hyperdrive. + * Used during provision to resolve metadata for pinned image tags. + * Returns regardless of status — pinning is an admin override that + * should work even for disabled versions. + * Returns null if the tag is not found. + */ +export async function lookupCatalogVersion( + connectionString: string, + imageTag: string +): Promise { + const db = getWorkerDb(connectionString); + const [row] = await db + .select({ + openclaw_version: kiloclaw_image_catalog.openclaw_version, + variant: kiloclaw_image_catalog.variant, + image_tag: kiloclaw_image_catalog.image_tag, + image_digest: kiloclaw_image_catalog.image_digest, + published_at: kiloclaw_image_catalog.published_at, + }) + .from(kiloclaw_image_catalog) + .where(eq(kiloclaw_image_catalog.image_tag, imageTag)) + .limit(1); + + if (!row) return null; + + return { + openclawVersion: row.openclaw_version, + variant: row.variant, + imageTag: row.image_tag, + imageDigest: row.image_digest, + publishedAt: row.published_at, + }; +} + /** * Upsert a version entry into the Postgres catalog. * Uses ON CONFLICT to update existing entries but never re-enables diff --git a/kiloclaw/src/lib/image-version.ts b/kiloclaw/src/lib/image-version.ts index 95becd610..22b0432c8 100644 --- a/kiloclaw/src/lib/image-version.ts +++ b/kiloclaw/src/lib/image-version.ts @@ -167,6 +167,39 @@ async function rebuildIndex(kv: KVNamespace): Promise { return tags; } +// --------------------------------------------------------------------------- +// Resolve a specific version by image tag (for pinned users) +// --------------------------------------------------------------------------- + +/** + * Find a version entry in KV by its image tag. + * Scans versioned keys (not latest pointers) and returns the first match. + * Returns null if no entry matches the given tag. + */ +export async function resolveVersionByTag( + kv: KVNamespace, + imageTag: string +): Promise { + let cursor: string | undefined; + + do { + const result = await kv.list({ prefix: 'image-version:', cursor }); + for (const key of result.keys) { + if (key.name.startsWith('image-version:latest:') || key.name === IMAGE_VERSION_INDEX_KEY) { + continue; + } + const raw = await kv.get(key.name, 'json'); + const parsed = ImageVersionEntrySchema.safeParse(raw); + if (parsed.success && parsed.data.imageTag === imageTag) { + return parsed.data; + } + } + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return null; +} + // --------------------------------------------------------------------------- // List all versions (for admin tooling / triggerSync) // --------------------------------------------------------------------------- diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 750e74776..7ba6e1900 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -19,7 +19,7 @@ import { imageVersionKey, imageVersionLatestKey, } from '../schemas/image-version'; -import { listAllVersions, updateTagIndex } from '../lib/image-version'; +import { listAllVersions, resolveLatestVersion, updateTagIndex } from '../lib/image-version'; import { upsertCatalogVersion } from '../lib/catalog-registration'; import { z } from 'zod'; import { withDORetry } from '@kilocode/worker-utils'; @@ -135,6 +135,7 @@ platform.post('/provision', async c => { kilocodeDefaultModel, machineSize, region, + pinnedImageTag, } = result.data; try { @@ -150,6 +151,7 @@ platform.post('/provision', async c => { kilocodeDefaultModel, machineSize, region, + pinnedImageTag, }), 'provision' ); @@ -579,6 +581,19 @@ platform.get('/versions', async c => { } }); +// GET /api/platform/versions/latest +// Returns the current :latest image version from KV. +platform.get('/versions/latest', async c => { + try { + const latest = await resolveLatestVersion(c.env.KV_CLAW_CACHE, 'default'); + if (!latest) return c.json({ error: 'No latest version registered' }, 404); + return c.json(latest); + } catch (err) { + console.error('[platform] Failed to get latest version:', err); + return c.json({ error: 'Failed to get latest version' }, 500); + } +}); + // POST /api/platform/publish-image-version // Manual fallback for publishing/correcting version entries. // Primary registration path is worker self-registration on deploy. diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 1fb43f4e3..27f377efd 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -50,6 +50,9 @@ export const InstanceConfigSchema = z.object({ // Examples: "us,eu" (try US first, then Europe), "lhr" (London only). // If omitted, falls back to the FLY_REGION env var. region: z.string().optional(), + // If set, use this image tag instead of resolving latest from KV. + // Set by the cloud app when the user has a version pin. + pinnedImageTag: z.string().optional(), }); export type InstanceConfig = z.infer; @@ -130,6 +133,7 @@ export const PersistedStateSchema = z.object({ openclawVersion: z.string().nullable().default(null), imageVariant: z.string().nullable().default(null), trackedImageTag: z.string().nullable().default(null), + trackedImageDigest: z.string().nullable().default(null), }); export type PersistedState = z.infer; diff --git a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts index 5a3eb5c04..77c789faa 100644 --- a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -19,6 +19,10 @@ const baseStatus: KiloClawDashboardStatus = { flyVolumeId: null, flyRegion: null, machineSize: null, + openclawVersion: null, + imageVariant: null, + trackedImageTag: null, + trackedImageDigest: null, gatewayToken: 'token', workerUrl: 'https://claw.kilo.ai', }; diff --git a/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx b/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx index aa4d0946c..06586f11d 100644 --- a/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx +++ b/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx @@ -188,7 +188,7 @@ function VersionPinCard({ userId }: { userId: string }) { Version Pin - Pin this user to a specific KiloClaw image version + Pin this user to a specific KiloClaw image tag {pinLoading ? ( @@ -199,13 +199,13 @@ function VersionPinCard({ userId }: { userId: string }) { ) : pinData ? (
- - - Pinned to {pinData.openclaw_version ?? pinData.image_tag} + + + {pinData.image_tag} - - {pinData.image_tag} + + {pinData.openclaw_version ?? '—'} {pinData.variant ?? 'default'} @@ -216,12 +216,12 @@ function VersionPinCard({ userId }: { userId: string }) {
- + {versionsData?.items.map(v => ( - {v.openclaw_version} ({v.variant}) - {v.image_tag.slice(0, 16)} + {v.image_tag} (OpenClaw {v.openclaw_version}) ))} @@ -628,6 +628,30 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { {data.workerStatus.secretCount} {data.workerStatus.channelCount} + + + {data.workerStatus.openclawVersion ?? '—'} + + + + {data.workerStatus.imageVariant ?? '—'} + + + + {data.workerStatus.trackedImageTag ? ( + {data.workerStatus.trackedImageTag} + ) : ( + '—' + )} + + + + {data.workerStatus.trackedImageDigest ? ( + {data.workerStatus.trackedImageDigest} + ) : ( + '—' + )} +
) : !data.workerStatusError ? (

No worker status available

diff --git a/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx b/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx index 7e51a962a..b59355410 100644 --- a/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx +++ b/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx @@ -70,6 +70,10 @@ export function VersionsTab() { }) ); + const { data: latestTag } = useQuery( + trpc.admin.kiloclawVersions.getLatestTag.queryOptions() + ); + const { mutateAsync: updateStatus } = useMutation( trpc.admin.kiloclawVersions.updateVersionStatus.mutationOptions({ onSuccess: () => { @@ -84,6 +88,20 @@ export function VersionsTab() { }) ); + const { mutateAsync: syncCatalog, isPending: isSyncing } = useMutation( + trpc.admin.kiloclawVersions.syncCatalog.mutationOptions({ + onSuccess: result => { + toast.success(`Sync complete: ${result.synced} added, ${result.skipped} already existed`); + void queryClient.invalidateQueries({ + queryKey: trpc.admin.kiloclawVersions.listVersions.queryKey(), + }); + }, + onError: err => { + toast.error(`Sync failed: ${err.message}`); + }, + }) + ); + return (
@@ -105,6 +123,14 @@ export function VersionsTab() { Disabled +
@@ -139,11 +165,18 @@ export function VersionsTab() { {version.openclaw_version} {version.variant} - - {version.image_tag.length > 20 - ? `${version.image_tag.slice(0, 20)}…` - : version.image_tag} - +
+ + {version.image_tag.length > 20 + ? `${version.image_tag.slice(0, 20)}…` + : version.image_tag} + + {latestTag === version.image_tag && ( + + latest + + )} +
@@ -365,16 +398,16 @@ export function PinsTab() { )}
-
- +
+ -
@@ -172,7 +167,7 @@ export function VersionsTab() { : version.image_tag}
{latestTag === version.image_tag && ( - + latest )} diff --git a/src/routers/admin-kiloclaw-versions-router.ts b/src/routers/admin-kiloclaw-versions-router.ts index 10c90a235..74d32fa5c 100644 --- a/src/routers/admin-kiloclaw-versions-router.ts +++ b/src/routers/admin-kiloclaw-versions-router.ts @@ -213,7 +213,7 @@ export const adminKiloclawVersionsRouter = createTRPCRouter({ .returning(); } catch (err) { const msg = err instanceof Error ? err.message : ''; - if (msg.includes('foreign key') || msg.includes('violates')) { + if (msg.includes('foreign key')) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Image tag '${input.imageTag}' not found in catalog`, @@ -271,7 +271,7 @@ export const adminKiloclawVersionsRouter = createTRPCRouter({ // Early return if KV has no versions if (kvVersions.length === 0) { - return { synced: 0, skipped: 0, total: 0 }; + return { synced: 0, alreadyExisted: 0, invalid: 0, total: 0 }; } // Fetch only existing tags that match KV versions (more memory-efficient) @@ -285,10 +285,11 @@ export const adminKiloclawVersionsRouter = createTRPCRouter({ ).map(row => row.image_tag) ); - // Filter out entries that already exist + // Filter out entries that already exist in Postgres const newEntries = kvVersions.filter(entry => !existingTags.has(entry.imageTag)); - // Validate entries before inserting — KV data may be malformed + // Validate entries before inserting — KV data may be malformed. + // Uses the same rules as validateEntry() in catalog-registration.ts. const IMAGE_TAG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; const VERSION_RE = /^\d{4}\.\d{1,2}\.\d{1,2}$/; const VARIANT_RE = /^[a-z0-9-]{1,64}$/; @@ -300,23 +301,32 @@ export const adminKiloclawVersionsRouter = createTRPCRouter({ if (isNaN(ts)) return false; return true; }); - const skipped = kvVersions.length - validEntries.length; + const invalidCount = newEntries.length - validEntries.length; - // Bulk insert validated new entries + // Bulk insert validated new entries. onConflictDoNothing guards against + // concurrent syncs inserting the same tag between our check and insert. if (validEntries.length > 0) { - await db.insert(kiloclaw_image_catalog).values( - validEntries.map(entry => ({ - openclaw_version: entry.openclawVersion, - variant: entry.variant, - image_tag: entry.imageTag, - image_digest: entry.imageDigest, - status: 'available' as const, - published_at: entry.publishedAt, - })) - ); + await db + .insert(kiloclaw_image_catalog) + .values( + validEntries.map(entry => ({ + openclaw_version: entry.openclawVersion, + variant: entry.variant, + image_tag: entry.imageTag, + image_digest: entry.imageDigest, + status: 'available' as const, + published_at: entry.publishedAt, + })) + ) + .onConflictDoNothing(); } - return { synced: validEntries.length, skipped, total: kvVersions.length }; + return { + synced: validEntries.length, + alreadyExisted: existingTags.size, + invalid: invalidCount, + total: kvVersions.length, + }; }), searchUsers: adminProcedure From 2fac7e93dfda3e7c04bbdbfd1b906b14d72f21ca Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 3 Mar 2026 07:04:59 -0800 Subject: [PATCH 7/8] fix: mock KiloClawInternalClient in tests, narrow FK error match --- .../admin-kiloclaw-versions-router.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/routers/admin-kiloclaw-versions-router.test.ts b/src/routers/admin-kiloclaw-versions-router.test.ts index dbb978c2e..751c04c19 100644 --- a/src/routers/admin-kiloclaw-versions-router.test.ts +++ b/src/routers/admin-kiloclaw-versions-router.test.ts @@ -6,6 +6,22 @@ import { db } from '@/lib/drizzle'; import { kiloclaw_image_catalog, kiloclaw_version_pins } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; +// Mock KiloClawInternalClient so tests don't require KILOCLAW_API_URL. +// getLatestVersion returns null (no latest set) so disable-latest guard passes. +jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => ({ + KiloClawInternalClient: jest.fn().mockImplementation(() => ({ + getLatestVersion: jest.fn().mockResolvedValue(null), + listVersions: jest.fn().mockResolvedValue([]), + })), + KiloClawApiError: class extends Error { + readonly statusCode: number; + constructor(statusCode: number) { + super(`KiloClaw API error (${statusCode})`); + this.statusCode = statusCode; + } + }, +})); + let regularUser: User; let adminUser: User; let targetUser: User; From 9a9011294cca64a1ffd319710a37ba6eeeac277d Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 3 Mar 2026 08:08:14 -0800 Subject: [PATCH 8/8] Added a console.error when KV misses and HYPERDRIVE isn't configured, so it's clearly visible in logs. --- kiloclaw/src/durable-objects/kiloclaw-instance.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index 6ea6535a1..e0c49b160 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -500,6 +500,12 @@ export class KiloClawInstance extends DurableObject { let pinned = await resolveVersionByTag(this.env.KV_CLAW_CACHE, config.pinnedImageTag); // Fall back to Postgres catalog (authoritative, has all synced versions) + if (!pinned && !this.env.HYPERDRIVE?.connectionString) { + console.error( + '[DO] HYPERDRIVE not configured — cannot look up pinned tag in Postgres:', + config.pinnedImageTag + ); + } if (!pinned && this.env.HYPERDRIVE?.connectionString) { try { const catalogEntry = await lookupCatalogVersion(