- Machine is running with the new size.
+ {isNorthflankProvider
+ ? 'Northflank completed the deployment rollout.'
+ : 'Machine is running with the new size.'}
+ {isNorthflankProvider && (
+
+
+ Northflank applies the compute change through a deployment rollout. The worker
+ waits for Northflank to report completion before saving the new tier.
+
+
+ )}
{data?.workerStatus?.provider === 'fly' &&
getTier(selectedInstanceType).volumeSizeGb >
(data?.workerStatus?.volumeSizeGb ?? 10) && (
@@ -3456,6 +3487,18 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
)}
+ {isNorthflankProvider &&
+ getTier(selectedInstanceType).volumeSizeGb >
+ (data?.workerStatus?.volumeSizeGb ?? 10) && (
+
+
+
+ Northflank volume will grow from {data?.workerStatus?.volumeSizeGb ?? 10} GB
+ to {getTier(selectedInstanceType).volumeSizeGb} GB. Volumes can grow but
+ cannot be shrunk.
+
+
+ )}
{data?.workerStatus?.provider === 'docker-local' &&
getTier(selectedInstanceType).volumeSizeGb !==
(data?.workerStatus?.volumeSizeGb ?? 10) && (
diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
index 55fb7541c..fdab70522 100644
--- a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
+++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
@@ -9225,21 +9225,84 @@ describe('resizeMachine', () => {
expect(storage._store.get('instanceType')).toBe('perf-4-8');
});
- it('rejects Northflank resize before persisting tier changes', async () => {
+ it('persists Northflank resize after volume and deployment plan updates are accepted', async () => {
const { instance, storage } = createInstance();
- await seedProvisioned(storage, {
+ await seedNorthflankInstance(storage, {
provider: 'northflank',
- providerState: { provider: 'northflank' },
- status: 'stopped',
+ providerState: northflankProviderState(),
+ status: 'running',
+ instanceType: 'perf-1-3',
+ machineSize: { cpus: 1, memory_mb: 3072, cpu_kind: 'performance' },
+ volumeSizeGb: 10,
+ });
+ vi.mocked(fetch).mockImplementation(async input => {
+ const url = fetchInputUrl(input);
+ if (url.endsWith('/volumes/volume-1')) {
+ return new Response(JSON.stringify({ data: {} }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ if (url.endsWith('/services/deployment/service-1')) {
+ return new Response(JSON.stringify({ data: { id: 'service-1', name: 'kc-ki-test' } }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ throw new Error(`Unhandled Northflank API request: ${url}`);
+ });
+
+ const result = await instance.resizeMachine('perf-4-8');
+
+ expect(result.newTier).toBe('perf-4-8');
+ expect(storage._store.get('instanceType')).toBe('perf-4-8');
+ expect(storage._store.get('machineSize')).toEqual({
+ cpus: 4,
+ memory_mb: 8192,
+ cpu_kind: 'performance',
+ });
+ expect(storage._store.get('volumeSizeGb')).toBe(20);
+ expect(storage._store.get('providerState')).toEqual(
+ expect.objectContaining({ ingressHost: 'kc-ki-test.code.run' })
+ );
+ });
+
+ it('leaves Northflank tier state unchanged when provider resize fails', async () => {
+ const { instance, storage } = createInstance();
+ await seedNorthflankInstance(storage, {
+ status: 'running',
instanceType: 'perf-1-3',
machineSize: { cpus: 1, memory_mb: 3072, cpu_kind: 'performance' },
volumeSizeGb: 10,
});
+ vi.mocked(fetch).mockImplementation(async input => {
+ const url = fetchInputUrl(input);
+ if (url.endsWith('/volumes/volume-1')) {
+ return new Response(JSON.stringify({ data: {} }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ if (url.endsWith('/services/deployment/service-1')) {
+ return new Response(JSON.stringify({ error: 'deployment patch failed' }), {
+ status: 500,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ throw new Error(`Unhandled Northflank API request: ${url}`);
+ });
await expect(instance.resizeMachine('perf-4-8')).rejects.toThrow(
- 'Instance tier resize is not yet supported on Northflank instances'
+ 'Northflank API patchDeploymentService failed (500)'
);
+
expect(storage._store.get('instanceType')).toBe('perf-1-3');
+ expect(storage._store.get('machineSize')).toEqual({
+ cpus: 1,
+ memory_mb: 3072,
+ cpu_kind: 'performance',
+ });
+ expect(storage._store.get('volumeSizeGb')).toBe(10);
});
it('clears any active admin size override and reports it in the response', async () => {
diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
index 54db7f4e6..98c347b40 100644
--- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
+++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
@@ -2962,9 +2962,11 @@ export class KiloClawInstance extends DurableObject {
cannotPrefix: 'resize',
beforePhrase: 'resizing machine tier',
notSupportedSubject: 'Instance tier resize',
- // Tier resize calls fly.extendVolume on storage growth; Fly volume
- // extends require the machine to be stopped.
- requireStopped: true,
+ // Fly tier resize calls fly.extendVolume on storage growth, which
+ // requires the machine to be stopped. Northflank uses deployment
+ // rollout semantics and does not require a stopped instance.
+ requireStopped: this.s.provider !== 'northflank',
+ allowNorthflank: true,
});
const targetTier = getTier(targetTierKey);
@@ -3004,6 +3006,18 @@ export class KiloClawInstance extends DurableObject {
await this.persist({ volumeSizeGb: targetTier.volumeSizeGb });
}
+ if (this.s.provider === 'northflank') {
+ const result = await this.provider().resizeRuntime?.({
+ env: this.env,
+ state: this.s,
+ targetTier: targetTier.key,
+ });
+ if (!result) {
+ throw new Error('Provider northflank does not support tier resize');
+ }
+ await this.persistProviderResult(result);
+ }
+
// Capture and clear any active admin override before applying the tier
// change. The customer is now paying for the new tier; carrying a
// pre-existing override would either silently downgrade them (override
@@ -3091,6 +3105,7 @@ export class KiloClawInstance extends DurableObject {
beforePhrase?: string;
notSupportedSubject: string;
requireStopped: boolean;
+ allowNorthflank?: boolean;
}): void {
if (!this.s.userId) {
throw new Error('Instance is not provisioned');
@@ -3115,7 +3130,7 @@ export class KiloClawInstance extends DurableObject {
{ status: 409 }
);
}
- if (this.s.provider === 'northflank') {
+ if (this.s.provider === 'northflank' && args.allowNorthflank !== true) {
throw new Error(`${args.notSupportedSubject} is not yet supported on Northflank instances`);
}
}
diff --git a/services/kiloclaw/src/northflank/client.test.ts b/services/kiloclaw/src/northflank/client.test.ts
index 5af648875..857235fcd 100644
--- a/services/kiloclaw/src/northflank/client.test.ts
+++ b/services/kiloclaw/src/northflank/client.test.ts
@@ -13,6 +13,7 @@ import {
isNorthflankNotFound,
listServices,
putProjectSecret,
+ updateVolume,
} from './client';
import { getNorthflankConfig } from './config';
import type { NorthflankClientConfig } from './client';
@@ -154,6 +155,37 @@ describe('Northflank Worker fetch client', () => {
expect(url).toBe('https://api.northflank.com/v1/projects/project-1/volumes/kc-ki-test');
});
+ it('updates volume storage size through the documented empty response endpoint', async () => {
+ const fetchMock = mockFetchSequence([[200, { data: {} }]]);
+
+ await expect(
+ updateVolume(config, 'project-1', 'volume-1', { storageSizeMb: 20480 })
+ ).resolves.toBeUndefined();
+
+ const [url, init] = firstFetchCall(fetchMock);
+ expect(url).toBe('https://api.northflank.com/v1/projects/project-1/volumes/volume-1');
+ const requestInit = expectRequestInit(init);
+ expect(requestInit.method).toBe('POST');
+ expect(JSON.parse(expectStringBody(requestInit.body))).toEqual({
+ spec: {
+ storageSize: 20480,
+ },
+ });
+ });
+
+ it('updates volumes through team-scoped routes when a team ID is configured', async () => {
+ const fetchMock = mockFetchSequence([[200, { data: {} }]]);
+
+ await updateVolume({ ...config, teamId: 'team-1' }, 'project-1', 'volume-1', {
+ storageSizeMb: 40960,
+ });
+
+ const [url] = firstFetchCall(fetchMock);
+ expect(url).toBe(
+ 'https://api.northflank.com/v1/teams/team-1/projects/project-1/volumes/volume-1'
+ );
+ });
+
it('finds services by deterministic name with a direct GET', async () => {
const fetchMock = mockFetchSequence([
[200, { data: { id: 'kc-ki-test', name: 'kc-ki-test' } }],
diff --git a/services/kiloclaw/src/northflank/client.ts b/services/kiloclaw/src/northflank/client.ts
index cdbc2b2b0..aa943e5ae 100644
--- a/services/kiloclaw/src/northflank/client.ts
+++ b/services/kiloclaw/src/northflank/client.ts
@@ -55,6 +55,7 @@ const NorthflankSecretDetailsSchema = z.object({ id: z.string(), name: z.string(
const ProjectResponseSchema = z.object({ data: NorthflankProjectSchema });
const VolumeResponseSchema = z.object({ data: NorthflankVolumeSchema });
const VolumeListResponseSchema = z.object({ data: z.array(NorthflankVolumeSchema) });
+const EmptyDataResponseSchema = z.object({ data: z.object({}).passthrough() });
const ServiceResponseSchema = z.object({ data: NorthflankServiceSchema });
const ServiceListResponseSchema = z.object({
data: z.object({ services: z.array(NorthflankServiceSchema) }).passthrough(),
@@ -419,6 +420,26 @@ export async function getVolume(
return response.data;
}
+export async function updateVolume(
+ config: NorthflankClientConfig,
+ projectId: string,
+ volumeId: string,
+ input: { storageSizeMb: number }
+): Promise {
+ await requestJson(
+ config,
+ `/projects/${encodeURIComponent(projectId)}/volumes/${encodeURIComponent(volumeId)}`,
+ jsonInit('POST', {
+ spec: {
+ storageSize: input.storageSizeMb,
+ },
+ }),
+ EmptyDataResponseSchema,
+ [200],
+ 'updateVolume'
+ );
+}
+
export async function deleteVolume(
config: NorthflankClientConfig,
projectId: string,
diff --git a/services/kiloclaw/src/northflank/config.test.ts b/services/kiloclaw/src/northflank/config.test.ts
index c91b584e4..afc5727a8 100644
--- a/services/kiloclaw/src/northflank/config.test.ts
+++ b/services/kiloclaw/src/northflank/config.test.ts
@@ -19,8 +19,8 @@ describe('getNorthflankConfig', () => {
deploymentPlan: 'nf-compute-200',
deploymentPlans: {
'perf-1-3': 'nf-compute-200',
- 'perf-4-8': 'nf-compute-200',
- 'perf-4-16': 'nf-compute-200',
+ 'perf-4-8': 'nf-compute-400',
+ 'perf-4-16': 'nf-compute-400-16',
},
storageClassName: 'nf-multi-rw',
storageAccessMode: 'ReadWriteMany',
@@ -54,8 +54,8 @@ describe('getNorthflankConfig', () => {
deploymentPlan: 'nf-compute-200',
deploymentPlans: {
'perf-1-3': 'nf-compute-200',
- 'perf-4-8': 'nf-compute-200',
- 'perf-4-16': 'nf-compute-200',
+ 'perf-4-8': 'nf-compute-400',
+ 'perf-4-16': 'nf-compute-400-16',
},
storageClassName: 'nf-ssd-rwo',
storageAccessMode: 'ReadWriteOnce',
@@ -79,6 +79,21 @@ describe('getNorthflankConfig', () => {
'NF_VOLUME_SIZE_MB must be a positive integer'
);
});
+
+ it('accepts explicit per-tier deployment plan overrides', () => {
+ expect(
+ getNorthflankConfig({
+ ...baseEnv,
+ NF_DEPLOYMENT_PLAN_PERF_1_3: 'custom-1-3',
+ NF_DEPLOYMENT_PLAN_PERF_4_8: 'custom-4-8',
+ NF_DEPLOYMENT_PLAN_PERF_4_16: 'custom-4-16',
+ } as never).deploymentPlans
+ ).toEqual({
+ 'perf-1-3': 'custom-1-3',
+ 'perf-4-8': 'custom-4-8',
+ 'perf-4-16': 'custom-4-16',
+ });
+ });
});
describe('northflankClientConfig', () => {
diff --git a/services/kiloclaw/src/northflank/config.ts b/services/kiloclaw/src/northflank/config.ts
index 2b226f653..ad29bd272 100644
--- a/services/kiloclaw/src/northflank/config.ts
+++ b/services/kiloclaw/src/northflank/config.ts
@@ -3,6 +3,9 @@ import type { NorthflankClientConfig } from './client';
import type { InstanceTierKey } from '@kilocode/kiloclaw-instance-tiers';
export const NORTHFLANK_API_BASE = 'https://api.northflank.com/v1';
+const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_1_3 = 'nf-compute-200';
+const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_8 = 'nf-compute-400';
+const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_16 = 'nf-compute-400-16';
export type NorthflankConfig = {
apiToken: string;
@@ -59,11 +62,14 @@ export function getNorthflankConfig(env: KiloClawEnv): NorthflankConfig {
deploymentPlan: requiredEnv(env, 'NF_DEPLOYMENT_PLAN'),
deploymentPlans: {
'perf-1-3':
- optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_1_3') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'),
+ optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_1_3') ??
+ DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_1_3,
'perf-4-8':
- optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_8') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'),
+ optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_8') ??
+ DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_8,
'perf-4-16':
- optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_16') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'),
+ optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_16') ??
+ DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_16,
},
storageClassName: optionalEnv(env, 'NF_STORAGE_CLASS_NAME') ?? 'nf-multi-rw',
storageAccessMode: optionalEnv(env, 'NF_STORAGE_ACCESS_MODE') ?? 'ReadWriteMany',
diff --git a/services/kiloclaw/src/providers/northflank/index.test.ts b/services/kiloclaw/src/providers/northflank/index.test.ts
index 738401ef7..0507f3990 100644
--- a/services/kiloclaw/src/providers/northflank/index.test.ts
+++ b/services/kiloclaw/src/providers/northflank/index.test.ts
@@ -15,6 +15,7 @@ import {
getVolume,
patchDeploymentService,
putProjectSecret,
+ updateVolume,
waitForDeploymentCompleted,
} from '../../northflank/client';
@@ -66,6 +67,7 @@ vi.mock('../../northflank/client', () => ({
isNorthflankNotFound: vi.fn(() => false),
patchDeploymentService: vi.fn(),
putProjectSecret: vi.fn(),
+ updateVolume: vi.fn(),
waitForDeploymentCompleted: vi.fn(),
}));
@@ -364,7 +366,7 @@ describe('northflankProviderAdapter', () => {
});
const createPayload = vi.mocked(createDeploymentService).mock.calls[0]?.[2];
- expect(createPayload?.billing.deploymentPlan).toBe('nf-compute-200');
+ expect(createPayload?.billing.deploymentPlan).toBe('nf-compute-400-16');
});
it('falls back and warns for legacy Northflank tier labels', async () => {
@@ -432,6 +434,131 @@ describe('northflankProviderAdapter', () => {
expect(result.observation?.runtimeState).toBe('stopped');
});
+ it('resizes runtime by growing volume and patching the accepted deployment plan', async () => {
+ vi.mocked(updateVolume).mockResolvedValue(undefined);
+ vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' });
+
+ const result = await northflankProviderAdapter.resizeRuntime?.({
+ env: { ...env, NF_DEPLOYMENT_PLAN_PERF_4_8: 'nf-compute-perf-4-8' } as never,
+ state: {
+ sandboxId: 'ki_123',
+ volumeSizeGb: 10,
+ providerState: {
+ provider: 'northflank',
+ projectId: 'project-1',
+ serviceId: 'service-1',
+ serviceName: 'kc-ki-123',
+ volumeId: 'volume-1',
+ ingressHost: 'old.code.run',
+ },
+ } as never,
+ targetTier: 'perf-4-8',
+ });
+
+ expect(updateVolume).toHaveBeenCalledWith(
+ expect.objectContaining({ apiToken: 'nf-token' }),
+ 'project-1',
+ 'volume-1',
+ {
+ storageSizeMb: 20480,
+ }
+ );
+ expect(patchDeploymentService).toHaveBeenCalledWith(
+ expect.objectContaining({ apiToken: 'nf-token' }),
+ 'project-1',
+ 'service-1',
+ { billing: { deploymentPlan: 'nf-compute-perf-4-8' } }
+ );
+ expect(waitForDeploymentCompleted).not.toHaveBeenCalled();
+ expect(result?.providerState).toEqual(
+ expect.objectContaining({ provider: 'northflank', ingressHost: 'old.code.run' })
+ );
+ });
+
+ it('resizes runtime without volume update when storage does not grow', async () => {
+ vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' });
+
+ await northflankProviderAdapter.resizeRuntime?.({
+ env: env as never,
+ state: {
+ sandboxId: 'ki_123',
+ volumeSizeGb: 20,
+ providerState: {
+ provider: 'northflank',
+ projectId: 'project-1',
+ serviceId: 'service-1',
+ volumeId: 'volume-1',
+ },
+ } as never,
+ targetTier: 'perf-4-8',
+ });
+
+ expect(updateVolume).not.toHaveBeenCalled();
+ expect(patchDeploymentService).toHaveBeenCalledWith(
+ expect.anything(),
+ 'project-1',
+ 'service-1',
+ { billing: { deploymentPlan: 'nf-compute-400' } }
+ );
+ expect(waitForDeploymentCompleted).not.toHaveBeenCalled();
+ });
+
+ it('rejects resize when service ID is missing', async () => {
+ await expect(
+ northflankProviderAdapter.resizeRuntime?.({
+ env: env as never,
+ state: {
+ sandboxId: 'ki_123',
+ volumeSizeGb: 10,
+ providerState: { provider: 'northflank', projectId: 'project-1', volumeId: 'volume-1' },
+ } as never,
+ targetTier: 'perf-4-8',
+ })
+ ).rejects.toThrow('Northflank resize requires an existing deployment service');
+
+ expect(updateVolume).not.toHaveBeenCalled();
+ expect(patchDeploymentService).not.toHaveBeenCalled();
+ });
+
+ it('rejects resize when storage grows and volume ID is missing', async () => {
+ await expect(
+ northflankProviderAdapter.resizeRuntime?.({
+ env: env as never,
+ state: {
+ sandboxId: 'ki_123',
+ volumeSizeGb: 10,
+ providerState: { provider: 'northflank', projectId: 'project-1', serviceId: 'service-1' },
+ } as never,
+ targetTier: 'perf-4-8',
+ })
+ ).rejects.toThrow('Northflank resize requires an existing volume when storage grows');
+
+ expect(patchDeploymentService).not.toHaveBeenCalled();
+ });
+
+ it('propagates deployment patch failures from resize', async () => {
+ vi.mocked(updateVolume).mockResolvedValue(undefined);
+ vi.mocked(patchDeploymentService).mockRejectedValue(new Error('deployment patch failed'));
+
+ await expect(
+ northflankProviderAdapter.resizeRuntime?.({
+ env: env as never,
+ state: {
+ sandboxId: 'ki_123',
+ volumeSizeGb: 10,
+ providerState: {
+ provider: 'northflank',
+ projectId: 'project-1',
+ serviceId: 'service-1',
+ volumeId: 'volume-1',
+ },
+ } as never,
+ targetTier: 'perf-4-8',
+ })
+ ).rejects.toThrow('deployment patch failed');
+ expect(waitForDeploymentCompleted).not.toHaveBeenCalled();
+ });
+
it('skips writing the restricted secret on restart when bootstrap env is unchanged', async () => {
const matchingHash = await computeSecretStateHash('service-1', runtimeSpec.bootstrapEnv);
vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' });
diff --git a/services/kiloclaw/src/providers/northflank/index.ts b/services/kiloclaw/src/providers/northflank/index.ts
index 3d96e6c0c..a8b74e721 100644
--- a/services/kiloclaw/src/providers/northflank/index.ts
+++ b/services/kiloclaw/src/providers/northflank/index.ts
@@ -1,10 +1,11 @@
import type { CreateSecretRequest, CreateServiceDeploymentRequest } from '@northflank/js-client';
import type { NorthflankProviderState } from '../../schemas/instance-config';
import type { InstanceMutableState } from '../../durable-objects/kiloclaw-instance/types';
+import { DEFAULT_VOLUME_SIZE_GB } from '../../config';
import { getNorthflankProviderState } from '../../durable-objects/kiloclaw-instance/state';
import type { RuntimeSpec, InstanceProviderAdapter } from '../types';
import { northflankClientConfig } from '../../northflank/config';
-import { DEFAULT_INSTANCE_TIER } from '@kilocode/kiloclaw-instance-tiers';
+import { DEFAULT_INSTANCE_TIER, getTier } from '@kilocode/kiloclaw-instance-tiers';
import { resolveNorthflankPlan } from '../../northflank/config';
import {
createDeploymentService,
@@ -26,6 +27,7 @@ import {
isNorthflankNotFound,
patchDeploymentService,
putProjectSecret,
+ updateVolume,
waitForDeploymentCompleted,
type NorthflankClientConfig,
type NorthflankProject,
@@ -836,6 +838,60 @@ export const northflankProviderAdapter: InstanceProviderAdapter = {
};
},
+ async resizeRuntime({ env, state, targetTier }) {
+ const config = northflankClientConfig(env);
+ const providerState = getNorthflankProviderState(state);
+ if (!providerState.projectId || !providerState.serviceId) {
+ throw new Error('Northflank resize requires an existing deployment service');
+ }
+
+ const tier = getTier(targetTier);
+ const currentVolumeSizeGb = state.volumeSizeGb ?? DEFAULT_VOLUME_SIZE_GB;
+ if (tier.volumeSizeGb > currentVolumeSizeGb) {
+ if (!providerState.volumeId) {
+ throw new Error('Northflank resize requires an existing volume when storage grows');
+ }
+ // Northflank's documented update-volume endpoint returns only an
+ // empty success response and does not expose a separate completion
+ // status to poll. Treat a successful 200 as the provider's accepted
+ // storage update; the persisted tier is desired state once Northflank
+ // accepts the storage and compute-plan updates.
+ await updateVolume(config, providerState.projectId, providerState.volumeId, {
+ storageSizeMb: tier.volumeSizeGb * 1024,
+ });
+ }
+
+ const deploymentPlan = resolveNorthflankDeploymentPlan(config, targetTier, state.sandboxId);
+ logNorthflank('resize_runtime_patch_service', {
+ description: 'Patching Northflank service compute plan for instance tier resize',
+ apiOperation: 'PATCH /projects/{projectId}/services/deployment/{serviceId}',
+ sandboxId: state.sandboxId,
+ projectId: providerState.projectId,
+ serviceId: providerState.serviceId,
+ serviceName: providerState.serviceName,
+ targetTier,
+ deploymentPlan,
+ });
+ await patchDeploymentService(config, providerState.projectId, providerState.serviceId, {
+ billing: { deploymentPlan },
+ });
+
+ logNorthflank('resize_runtime_patch_accepted', {
+ description:
+ 'Northflank accepted the service compute-plan patch; persisted tier now reflects desired state',
+ apiOperation: 'PATCH /projects/{projectId}/services/deployment/{serviceId}',
+ sandboxId: state.sandboxId,
+ projectId: providerState.projectId,
+ serviceId: providerState.serviceId,
+ targetTier,
+ deploymentPlan,
+ });
+
+ return {
+ providerState,
+ };
+ },
+
async inspectRuntime({ env, state }) {
const config = northflankClientConfig(env);
const providerState = getNorthflankProviderState(state);
diff --git a/services/kiloclaw/src/providers/types.ts b/services/kiloclaw/src/providers/types.ts
index 05b69d5d6..ecd73aaa4 100644
--- a/services/kiloclaw/src/providers/types.ts
+++ b/services/kiloclaw/src/providers/types.ts
@@ -1,4 +1,5 @@
import type { KiloClawEnv } from '../types';
+import type { InstanceTierKey } from '@kilocode/kiloclaw-instance-tiers';
import type {
MachineSize,
PersistedState,
@@ -80,6 +81,10 @@ export type DestroyRuntimeArgs = ProviderContext;
export type DestroyStorageArgs = ProviderContext;
+export type ResizeRuntimeArgs = ProviderContext & {
+ targetTier: InstanceTierKey;
+};
+
export type InstanceProviderAdapter = {
readonly id: ProviderId;
readonly capabilities: ProviderCapabilities;
@@ -89,6 +94,7 @@ export type InstanceProviderAdapter = {
startRuntime(args: StartRuntimeArgs): Promise;
stopRuntime(args: StopRuntimeArgs): Promise;
restartRuntime(args: RestartRuntimeArgs): Promise;
+ resizeRuntime?(args: ResizeRuntimeArgs): Promise;
inspectRuntime(args: ProviderContext): Promise;
destroyRuntime(args: DestroyRuntimeArgs): Promise;
destroyStorage(args: DestroyStorageArgs): Promise;
diff --git a/services/kiloclaw/wrangler.jsonc b/services/kiloclaw/wrangler.jsonc
index 8b50e3c1a..8066153c5 100644
--- a/services/kiloclaw/wrangler.jsonc
+++ b/services/kiloclaw/wrangler.jsonc
@@ -138,13 +138,10 @@
"NF_TEAM_ID": "kilo-prod",
"NF_REGION": "us-central",
"NF_DEPLOYMENT_PLAN": "nf-compute-200",
- // Per-tier Northflank compute plan overrides are intentionally NOT
- // declared as empty defaults here — getNorthflankConfig falls back to
- // NF_DEPLOYMENT_PLAN when an override is missing, and committing empty
- // strings would silently propagate empty plans if optionalEnv ever
- // stops trimming. Set NF_DEPLOYMENT_PLAN_PERF_1_3 / _PERF_4_8 /
- // _PERF_4_16 per environment via `wrangler secret put` or env-specific
- // overrides when per-tier hardware on Northflank is needed.
+ // getNorthflankConfig has source-level defaults for tier compute plans:
+ // perf-1-3=nf-compute-200, perf-4-8=nf-compute-400,
+ // perf-4-16=nf-compute-400-16. Override NF_DEPLOYMENT_PLAN_PERF_*
+ // only if an environment needs different Northflank plan slugs.
"NF_EDGE_HEADER_NAME": "x-kiloclaw-northflank-edge-prod",
"NF_IMAGE_PATH_TEMPLATE": "ghcr.io/kilo-org/kiloclaw:{tag}",
"NF_IMAGE_CREDENTIALS_ID": "kiloclaw",