Skip to content
30 changes: 28 additions & 2 deletions kiloclaw/scripts/push-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 112 additions & 13 deletions kiloclaw/src/durable-objects/kiloclaw-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -381,6 +383,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
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;
Expand Down Expand Up @@ -421,6 +424,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
this.openclawVersion = s.openclawVersion;
this.imageVariant = s.imageVariant;
this.trackedImageTag = s.trackedImageTag;
this.trackedImageDigest = s.trackedImageDigest;
} else {
const hasAnyData = entries.size > 0;
if (hasAnyData) {
Expand Down Expand Up @@ -487,18 +491,97 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
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(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this correspond to? As in, where did this tag come from if we don't know about it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tag comes from the admin panel — an admin sets a version pin for a user via the Pins tab, which stores the image tag in the kiloclaw_version_pins Postgres table. When that user's instance is provisioned, the cloud app reads their pin and passes pinnedImageTag to the worker.

The tag might not be found in KV or the catalog if:

  1. The catalog was cleared/rebuilt and the tag wasn't re-synced
  2. The image was pushed directly to the Fly registry without going through registerVersionIfNeeded() (e.g. a manual docker push)
  3. Race condition during first deploy of a new version

In all these cases, the tag is still valid in the Fly registry — we just don't have metadata (version, variant, digest) for it. Provision proceeds with the raw tag and Fly resolves it. The warn log makes this visible for investigation.

'[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 = {
Expand All @@ -518,6 +601,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
openclawVersion: this.openclawVersion,
imageVariant: this.imageVariant,
trackedImageTag: this.trackedImageTag,
trackedImageDigest: this.trackedImageDigest,
};

const update = isNew
Expand Down Expand Up @@ -1070,6 +1154,14 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
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,
Expand Down Expand Up @@ -1253,6 +1345,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
openclawVersion: string | null;
imageVariant: string | null;
trackedImageTag: string | null;
trackedImageDigest: string | null;
}> {
await this.loadState();

Expand Down Expand Up @@ -1287,6 +1380,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
openclawVersion: this.openclawVersion,
imageVariant: this.imageVariant,
trackedImageTag: this.trackedImageTag,
trackedImageDigest: this.trackedImageDigest,
};
}

Expand Down Expand Up @@ -1527,19 +1621,22 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
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 {
// Custom tag: clear version metadata since we don't know what version this tag represents
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,
})
);
}
Expand Down Expand Up @@ -2112,6 +2209,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
this.openclawVersion = null;
this.imageVariant = null;
this.trackedImageTag = null;
this.trackedImageDigest = null;
this.loaded = false;
}

Expand Down Expand Up @@ -2544,6 +2642,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
this.openclawVersion = null;
this.imageVariant = null;
this.trackedImageTag = null;
this.trackedImageDigest = null;
this.loaded = true;

console.log('[DO] Restored from Postgres: sandboxId =', instance.sandboxId);
Expand Down
36 changes: 36 additions & 0 deletions kiloclaw/src/lib/catalog-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand Down Expand Up @@ -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<CatalogVersionEntry | null> {
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
Expand Down
Loading