Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
78dbae6
feat(kiloclaw): add KiloClawRegistry DO + wire provision/destroy/prox…
pandemicsyn Mar 29, 2026
f82197f
feat(kiloclaw): thread instanceId through all lifecycle callers
pandemicsyn Mar 29, 2026
0ee64e2
fix(kiloclaw): resolve real userId from DO in controller checkin
pandemicsyn Mar 29, 2026
5334409
fix(test): update admin destroyFlyMachine assertions for instanceId p…
pandemicsyn Mar 30, 2026
7b4dd2c
fix(kiloclaw): align DB/DO sandboxId identity and fix registry/admin …
pandemicsyn Mar 30, 2026
0e2d672
fix(kiloclaw): make getActiveInstance deterministic with ORDER BY cre…
pandemicsyn Mar 30, 2026
b02a1a9
fix(kiloclaw): try both registries on destroy when orgId is known
pandemicsyn Mar 30, 2026
a0303f1
fix(kiloclaw): fix billing instance reassignment for instance-keyed rows
pandemicsyn Mar 30, 2026
82d1ab9
fix(kiloclaw): derive access gateway token from DO sandboxId, not userId
pandemicsyn Mar 30, 2026
c8d38a0
fix(kiloclaw): include missed instanceId threading from stash
pandemicsyn Mar 30, 2026
843d052
fix(kiloclaw): don't pass instanceId to worker for legacy instances
pandemicsyn Mar 30, 2026
82d951e
fix(kiloclaw): fix access-gateway fallback and admin destroy missing …
pandemicsyn Mar 30, 2026
72e49b4
fix(kiloclaw): clean up registry entries on legacy destroy (no instan…
pandemicsyn Mar 30, 2026
94bc618
feat(kiloclaw): registry lifecycle hardening + admin observability
pandemicsyn Mar 31, 2026
6469185
fix(kiloclaw): registry admin endpoint queries org registry, real mig…
pandemicsyn Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions kiloclaw/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/sqlite-schema.ts',
dialect: 'sqlite',
driver: 'durable-sqlite',
});
7 changes: 7 additions & 0 deletions kiloclaw/drizzle/0000_messy_grim_reaper.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE `instances` (
`instance_id` text PRIMARY KEY NOT NULL,
`do_key` text NOT NULL,
`assigned_user_id` text NOT NULL,
`created_at` text NOT NULL,
`destroyed_at` text
);
63 changes: 63 additions & 0 deletions kiloclaw/drizzle/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f488eead-1986-472a-8973-f487dc7599bf",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"instances": {
"name": "instances",
"columns": {
"instance_id": {
"name": "instance_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"do_key": {
"name": "do_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"assigned_user_id": {
"name": "assigned_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destroyed_at": {
"name": "destroyed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
13 changes: 13 additions & 0 deletions kiloclaw/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1774809105002,
"tag": "0000_messy_grim_reaper",
"breakpoints": true
}
]
}
9 changes: 9 additions & 0 deletions kiloclaw/drizzle/migrations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import journal from './meta/_journal.json';
import m0000 from './0000_messy_grim_reaper.sql';

export default {
journal,
migrations: {
m0000,
},
};
1 change: 1 addition & 0 deletions kiloclaw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@types/node": "^22.19.15",
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "^4.1.0",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
"vitest": "^4.1.0",
"wrangler": "catalog:"
Expand Down
10 changes: 10 additions & 0 deletions kiloclaw/src/db/sqlite-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';

/** Registry DO SQLite table: tracks instance ownership per registry (user or org). */
export const registryInstances = sqliteTable('instances', {
instance_id: text('instance_id').primaryKey(),
do_key: text('do_key').notNull(),
assigned_user_id: text('assigned_user_id').notNull(),
created_at: text('created_at').notNull(),
destroyed_at: text('destroyed_at'),
});
68 changes: 67 additions & 1 deletion kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { DEFAULT_INSTANCE_FEATURES } from '../../schemas/instance-config';
import type { FlyVolume, FlyVolumeSnapshot } from '../../fly/types';
import * as fly from '../../fly/client';
import { sandboxIdFromUserId, sandboxIdFromInstanceId } from '../../auth/sandbox-id';
import {
isInstanceKeyedSandboxId,
instanceIdFromSandboxId,
} from '@kilocode/worker-utils/instance-id';
import { resolveLatestVersion, resolveVersionByTag } from '../../lib/image-version';
import { lookupCatalogVersion } from '../../lib/catalog-registration';
import { ImageVariantSchema } from '../../schemas/image-version';
Expand Down Expand Up @@ -927,7 +931,9 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
if (!this.s.userId || !this.s.sandboxId) {
const restoreUserId = userId ?? this.s.userId;
if (restoreUserId) {
await restoreFromPostgres(this.env, this.ctx, this.s, restoreUserId);
await restoreFromPostgres(this.env, this.ctx, this.s, restoreUserId, {
sandboxId: this.s.sandboxId,
});
}
}

Expand Down Expand Up @@ -1300,13 +1306,69 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
await tryDeleteMachine(flyConfig, this.ctx, this.s, destroyRctx);
await tryDeleteVolume(flyConfig, this.ctx, this.s, destroyRctx);

// Capture identity before finalization wipes state
const preDestroyUserId = this.s.userId;
const preDestroyOrgId = this.s.orgId;
const preDestroySandboxId = this.s.sandboxId;

const finalized = await finalizeDestroyIfComplete(
this.ctx,
this.s,
destroyRctx,
(userId, sandboxId) =>
markDestroyedInPostgresHelper(this.env, this.ctx, this.s, userId, sandboxId)
);

// Clean up registry entry on finalization. This covers both platform-initiated
// and alarm-initiated destroys. The platform route's registry cleanup is
// redundant but harmless (destroyInstance is idempotent on already-destroyed entries).
if (finalized.finalized && preDestroyUserId && preDestroySandboxId) {
try {
const registryInstanceId = isInstanceKeyedSandboxId(preDestroySandboxId)
? instanceIdFromSandboxId(preDestroySandboxId)
: null;

const registryKeys = [`user:${preDestroyUserId}`];
if (preDestroyOrgId) registryKeys.push(`org:${preDestroyOrgId}`);

for (const registryKey of registryKeys) {
const registryStub = this.env.KILOCLAW_REGISTRY.get(
this.env.KILOCLAW_REGISTRY.idFromName(registryKey)
);
if (registryInstanceId) {
await registryStub.destroyInstance(registryKey, registryInstanceId);
console.log('[DO] Registry entry destroyed on finalization:', {
registryKey,
instanceId: registryInstanceId,
});
} else {
// Legacy: find active entry by doKey=userId
const entries = await registryStub.listInstances(registryKey);
const legacyEntry = entries.find(e => e.doKey === preDestroyUserId);
if (legacyEntry) {
await registryStub.destroyInstance(registryKey, legacyEntry.instanceId);
console.log('[DO] Registry entry destroyed on finalization (legacy):', {
registryKey,
instanceId: legacyEntry.instanceId,
doKey: preDestroyUserId,
});
} else {
console.log(
'[DO] Registry cleanup: no active entry found (already cleaned or never existed):',
{
registryKey,
doKey: preDestroyUserId,
activeEntryCount: entries.length,
}
);
}
}
}
} catch (registryErr) {
console.error('[DO] Registry cleanup on finalization failed (non-fatal):', registryErr);
}
}

if (!finalized.finalized) {
doWarn(this.s, 'Destroy incomplete, alarm will retry', {
pendingMachineId: this.s.pendingDestroyMachineId,
Expand Down Expand Up @@ -1707,6 +1769,10 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
snapshotId,
previousVolumeId,
region: this.s.flyRegion,
instanceId:
this.s.sandboxId && isInstanceKeyedSandboxId(this.s.sandboxId)
? instanceIdFromSandboxId(this.s.sandboxId)
: undefined,
});
} catch (err) {
this.s.status = previousStatus;
Expand Down
25 changes: 22 additions & 3 deletions kiloclaw/src/durable-objects/kiloclaw-instance/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import type { KiloClawEnv } from '../../types';
import type { EncryptedEnvelope } from '../../schemas/instance-config';
import { getWorkerDb, getActiveInstance, markInstanceDestroyed } from '../../db';
import {
getWorkerDb,
getActiveInstance,
getInstanceBySandboxId,
markInstanceDestroyed,
} from '../../db';
import { appNameFromUserId } from '../../fly/apps';
import type { InstanceMutableState } from './types';
import { getFlyConfig } from './types';
import { storageUpdate } from './state';
import { attemptMetadataRecovery } from './reconcile';
import { doError, doWarn, toLoggable, createReconcileContext } from './log';

type RestoreOpts = {
/** If the DO has a stored sandboxId, use it for precise lookup. */
sandboxId?: string | null;
};

/**
* Restore DO state from Postgres backup if SQLite was wiped.
*
* Lookup priority:
* 1. If opts.sandboxId is provided, look up by sandbox_id (precise, multi-instance safe).
* 2. Otherwise, fall back to getActiveInstance(db, userId) (legacy single-instance).
*/
export async function restoreFromPostgres(
env: KiloClawEnv,
ctx: DurableObjectState,
state: InstanceMutableState,
userId: string
userId: string,
opts?: RestoreOpts
): Promise<void> {
const connectionString = env.HYPERDRIVE?.connectionString;
if (!connectionString) {
Expand All @@ -25,7 +40,11 @@ export async function restoreFromPostgres(

try {
const db = getWorkerDb(connectionString);
const instance = await getActiveInstance(db, userId);

// Prefer sandboxId lookup (multi-instance safe) over userId lookup (ambiguous).
const instance = opts?.sandboxId
? await getInstanceBySandboxId(db, opts.sandboxId)
: await getActiveInstance(db, userId);

if (!instance) {
doWarn(state, 'No active instance found in Postgres', { userId });
Expand Down
Loading
Loading