diff --git a/kiloclaw/scripts/push-dev.sh b/kiloclaw/scripts/push-dev.sh index c33f654b3..d38cdb848 100755 --- a/kiloclaw/scripts/push-dev.sh +++ b/kiloclaw/scripts/push-dev.sh @@ -26,22 +26,48 @@ 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 - sed -i '' "s/^FLY_IMAGE_TAG=.*/FLY_IMAGE_TAG=$TAG/" "$KILOCLAW_DIR/.dev.vars" + # Use temp file for cross-platform sed compatibility (macOS/Linux) + sed "s/^FLY_IMAGE_TAG=.*/FLY_IMAGE_TAG=$TAG/" "$KILOCLAW_DIR/.dev.vars" > "$KILOCLAW_DIR/.dev.vars.tmp" + mv "$KILOCLAW_DIR/.dev.vars.tmp" "$KILOCLAW_DIR/.dev.vars" 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 + # Use temp file for cross-platform sed compatibility (macOS/Linux) + sed "s|^FLY_IMAGE_DIGEST=.*|FLY_IMAGE_DIGEST=$DIGEST|" "$KILOCLAW_DIR/.dev.vars" > "$KILOCLAW_DIR/.dev.vars.tmp" + mv "$KILOCLAW_DIR/.dev.vars.tmp" "$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..e0c49b160 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 { ImageVariantSchema } 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,97 @@ 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.debug('[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) { + 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( + this.env.HYPERDRIVE.connectionString, + config.pinnedImageTag + ); + if (catalogEntry) { + // Validate variant from Postgres catalog against known variants + const variantParse = ImageVariantSchema.safeParse(catalogEntry.variant); + if (!variantParse.success) { + // Log error but treat as cache miss rather than failing provision + console.error( + '[DO] Invalid variant from Postgres catalog, skipping:', + catalogEntry.variant, + 'for tag:', + config.pinnedImageTag, + 'error:', + variantParse.error.flatten() + ); + // Continue without setting pinned - will fall through to error handling below + } else { + pinned = { + openclawVersion: catalogEntry.openclawVersion, + variant: variantParse.data, + imageTag: catalogEntry.imageTag, + imageDigest: catalogEntry.imageDigest, + publishedAt: catalogEntry.publishedAt, + }; + console.debug( + '[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.debug('[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. + // Clear version metadata to avoid stale values from a previous provision. + console.warn( + '[DO] Pinned tag not found in KV or Postgres, using tag directly:', + config.pinnedImageTag + ); + this.openclawVersion = null; + this.imageVariant = null; + 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 +601,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: this.openclawVersion, imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, + trackedImageDigest: this.trackedImageDigest, }; const update = isNew @@ -1070,6 +1154,14 @@ 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 +1345,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: string | null; imageVariant: string | null; trackedImageTag: string | null; + trackedImageDigest: string | null; }> { await this.loadState(); @@ -1287,6 +1380,7 @@ export class KiloClawInstance extends DurableObject { openclawVersion: this.openclawVersion, imageVariant: this.imageVariant, trackedImageTag: this.trackedImageTag, + trackedImageDigest: this.trackedImageDigest, }; } @@ -1527,6 +1621,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 +1629,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 +2209,7 @@ export class KiloClawInstance extends DurableObject { this.openclawVersion = null; this.imageVariant = null; this.trackedImageTag = null; + this.trackedImageDigest = null; this.loaded = false; } @@ -2544,6 +2642,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..304a68c99 100644 --- a/kiloclaw/src/lib/image-version.ts +++ b/kiloclaw/src/lib/image-version.ts @@ -7,6 +7,14 @@ import { import type { ImageVersionEntry, ImageVariant } from '../schemas/image-version'; import { upsertCatalogVersion } from './catalog-registration'; +/** + * KV key for direct tag-to-entry lookup. + * Enables O(1) resolution of pinned image tags during provision. + */ +function imageVersionTagKey(imageTag: string): string { + return `image-version-tag:${imageTag}`; +} + /** * Read `image-version:latest:` from KV. * Returns the full parsed ImageVersionEntry or null (single KV read). @@ -69,11 +77,12 @@ export async function registerVersionIfNeeded( publishedAt, }; - // Write to KV: versioned key + latest pointer + // Write to KV: versioned key + latest pointer + tag lookup key const serialized = JSON.stringify(entry); await Promise.all([ kv.put(imageVersionKey(openclawVersion, variant), serialized), kv.put(imageVersionLatestKey(variant), serialized), + kv.put(imageVersionTagKey(imageTag), serialized), ]); // Maintain KV tag index (best-effort) @@ -167,6 +176,71 @@ 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. + * Uses direct tag-to-entry lookup key for O(1) resolution. + * Falls back to scanning versioned keys if the tag lookup key is missing + * (for backward compatibility or cache misses), then backfills the lookup key. + * Returns null if no entry matches the given tag. + */ +export async function resolveVersionByTag( + kv: KVNamespace, + imageTag: string +): Promise { + // Fast path: direct tag lookup + const raw = await kv.get(imageVersionTagKey(imageTag), 'json'); + if (raw) { + const parsed = ImageVersionEntrySchema.safeParse(raw); + if (parsed.success) { + return parsed.data; + } + console.warn('[image-version] Invalid tag entry in KV:', imageTag, parsed.error.flatten()); + } + + // Fallback: scan versioned keys (for backward compatibility or cache misses). + // Capped at 5 pages (5000 keys) to prevent unbounded iteration. + console.warn('[image-version] Tag lookup key missing, falling back to scan for:', imageTag); + let cursor: string | undefined; + let pages = 0; + const MAX_SCAN_PAGES = 5; + do { + const result = await kv.list({ prefix: 'image-version:', cursor }); + pages++; + for (const key of result.keys) { + if ( + key.name.startsWith('image-version:latest:') || + key.name.startsWith('image-version-tag:') || + 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) { + // Backfill the tag lookup key for future requests (fire-and-forget) + kv.put(imageVersionTagKey(imageTag), JSON.stringify(parsed.data)).catch(err => + console.warn( + '[image-version] Failed to backfill tag lookup key:', + err instanceof Error ? err.message : err + ) + ); + return parsed.data; + } + } + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor && pages < MAX_SCAN_PAGES); + + if (cursor) { + console.warn('[image-version] Scan aborted after', MAX_SCAN_PAGES, 'pages for tag:', imageTag); + } + + return null; +} + // --------------------------------------------------------------------------- // List all versions (for admin tooling / triggerSync) // --------------------------------------------------------------------------- @@ -182,8 +256,12 @@ export async function listAllVersions(kv: KVNamespace): Promise { 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..0e652d325 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { IMAGE_TAG_RE, IMAGE_TAG_MAX_LENGTH } from '../lib/image-tag-validation'; export const EncryptedEnvelopeSchema = z.object({ // AES-256-GCM ciphertext: 16-byte IV + ciphertext + 16-byte tag, base64-encoded. @@ -50,6 +51,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().regex(IMAGE_TAG_RE).max(IMAGE_TAG_MAX_LENGTH).optional(), }); export type InstanceConfig = z.infer; @@ -130,6 +134,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..c4c2e3042 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,14 +199,10 @@ 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'} {pinData.pinned_by_email ?? pinData.pinned_by} @@ -216,12 +212,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 +624,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..3658f0969 100644 --- a/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx +++ b/src/app/admin/components/KiloclawVersions/KiloclawVersionsPage.tsx @@ -70,6 +70,8 @@ export function VersionsTab() { }) ); + const { data: latestTag } = useQuery(trpc.admin.kiloclawVersions.getLatestTag.queryOptions()); + const { mutateAsync: updateStatus } = useMutation( trpc.admin.kiloclawVersions.updateVersionStatus.mutationOptions({ onSuccess: () => { @@ -84,6 +86,22 @@ export function VersionsTab() { }) ); + const { mutateAsync: syncCatalog, isPending: isSyncing } = useMutation( + trpc.admin.kiloclawVersions.syncCatalog.mutationOptions({ + onSuccess: result => { + const parts = [`${result.synced} added`, `${result.alreadyExisted} already existed`]; + if (result.invalid > 0) parts.push(`${result.invalid} invalid`); + toast.success(`Sync complete: ${parts.join(', ')}`); + void queryClient.invalidateQueries({ + queryKey: trpc.admin.kiloclawVersions.listVersions.queryKey(), + }); + }, + onError: err => { + toast.error(`Sync failed: ${err.message}`); + }, + }) + ); + return (
@@ -105,6 +123,9 @@ export function VersionsTab() { Disabled +
@@ -139,11 +160,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 +393,16 @@ export function PinsTab() { )}
-
- +
+