Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 52 additions & 5 deletions services/cloud-agent-next/src/container-capacity-parity.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { parse } from 'jsonc-parser';
import { describe, expect, it } from 'vitest';

import { containerCapacityForService } from '../../../apps/web/src/lib/cloudflare/container-capacity.js';
import { SANDBOX_CAPACITIES, type SandboxClassName } from './container-usage-context.js';
import { CLOUDFLARE_CONTAINERS_INSTANCES } from '@kilocode/worker-utils/sandbox-allocation';

import {
containerCapacityForService,
formatContainerCapacity,
} from '../../../apps/web/src/lib/cloudflare/container-capacity.js';
import {
CONTAINERS_BILLING_CAPACITIES,
containersBillingIdentity,
SANDBOX_CAPACITIES,
type LegacySandboxClassName,
} from './container-usage-context.js';

type UnmeteredSandboxClassName = 'SandboxContainers';

type WranglerContainer = {
class_name: SandboxClassName | UnmeteredSandboxClassName;
class_name: LegacySandboxClassName | UnmeteredSandboxClassName;
instance_type?: {
vcpu: number;
memory_mib: number;
Expand All @@ -19,7 +30,7 @@ type WranglerContainer = {
};

type MeteredWranglerContainer = {
class_name: SandboxClassName;
class_name: LegacySandboxClassName;
instance_type: {
vcpu: number;
memory_mib: number;
Expand All @@ -31,7 +42,7 @@ type WranglerConfig = {
containers: WranglerContainer[];
};

const SERVICE_BY_CLASS: Record<SandboxClassName, string> = {
const SERVICE_BY_CLASS: Record<LegacySandboxClassName, string> = {
Sandbox: 'cloud-agent-next-sandbox',
SandboxContainment: 'cloud-agent-next-sandbox-containment',
SandboxSmall: 'cloud-agent-next-sandbox-small',
Expand All @@ -53,7 +64,9 @@ describe('production container capacity parity', () => {

const unmetered = config.containers.filter(container => !isMeteredContainer(container));
expect(unmetered).toHaveLength(1);
expect(unmetered[0]?.class_name).toBe('SandboxContainers');
expect(unmetered[0]?.instance_type).toBeUndefined();
expect('SandboxContainers' in SANDBOX_CAPACITIES).toBe(false);

const metered = config.containers.filter(isMeteredContainer);
const classNames = metered.map(container => container.class_name);
Expand All @@ -74,4 +87,38 @@ describe('production container capacity parity', () => {
});
}
});

it('matches containers billing capacities to the selectable instances and web labels', () => {
const resolved = CLOUDFLARE_CONTAINERS_INSTANCES.map(instance => ({
instance,
identity: containersBillingIdentity(instance),
}));
expect(new Set(resolved.map(entry => entry.identity.className))).toEqual(
new Set(Object.keys(CONTAINERS_BILLING_CAPACITIES))
);

const webSource = fs.readFileSync(
fileURLToPath(
new URL(
'../../../apps/web/src/components/cloud-agent-next/sandbox-selection.ts',
import.meta.url
).href
),
'utf8'
);
for (const { instance, identity } of resolved) {
const { capacity } = identity;
expect(webSource).toContain(
`'${instance}': '${formatContainerCapacity({
vcpu: capacity.vcpu,
memoryBytes: capacity.memoryMiB * 1024 ** 2,
diskBytes: capacity.diskMB * 1_000_000,
})}',`
);
}

// Web labels show vCPU and memory only; disk follows the Cloudflare instance-type table.
expect(CONTAINERS_BILLING_CAPACITIES.SandboxContainersStandard3.diskMB).toBe(16_000);
expect(CONTAINERS_BILLING_CAPACITIES.SandboxContainersStandard4.diskMB).toBe(20_000);
});
});
105 changes: 103 additions & 2 deletions services/cloud-agent-next/src/container-usage-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import type { SandboxInstance } from './types.js';
import type { SandboxId, SandboxInstance } from './types.js';
import type { SessionMetadata } from './persistence/session-metadata.js';
import {
assertSandboxBillingAllocation,
billingCapacityForSandboxClass,
buildSandboxBillingInput,
configureSandboxBillingInput,
containersBillingIdentity,
forceDestroyControlPlaneSandbox,
getSandboxBillingRuntimeStatus,
isContainersBillingClassName,
SANDBOX_CAPACITIES,
SANDBOX_USAGE_SKUS,
type SandboxClassName,
} from './container-usage-context.js';

function metadata(identity: SessionMetadata['identity']): SessionMetadata {
Expand Down Expand Up @@ -109,10 +113,107 @@ describe('container usage context', () => {
SandboxDIND: 'cloud-agent-dind-2026-07',
SandboxCodeReview: 'cloud-agent-code-review-2026-07',
SandboxCodeReviewContainment: 'cloud-agent-code-review-2026-07',
SandboxContainersStandard3: 'cloud-agent-containers-standard-3-2026-09',
SandboxContainersStandard4: 'cloud-agent-containers-standard-4-2026-09',
});
});

it('snapshots the configured capacity for every sandbox class', () => {
it('resolves a containers identity per instance size', () => {
expect(containersBillingIdentity('standard-3')).toEqual({
className: 'SandboxContainersStandard3',
service: 'cloud-agent-next-sandbox-containers-standard3',
sku: 'cloud-agent-containers-standard-3-2026-09',
capacity: { vcpu: 2, memoryMiB: 8_192, diskMB: 16_000 },
});
expect(containersBillingIdentity('standard-4')).toEqual({
className: 'SandboxContainersStandard4',
service: 'cloud-agent-next-sandbox-containers-standard4',
sku: 'cloud-agent-containers-standard-4-2026-09',
capacity: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
});

expect(() => containersBillingIdentity('lite')).toThrow(
'Containers billing is unsupported for instance size: lite'
);
expect(() => containersBillingIdentity('standard-1')).toThrow(
'Containers billing is unsupported for instance size: standard-1'
);
expect(() => containersBillingIdentity('standard-2')).toThrow(
'Containers billing is unsupported for instance size: standard-2'
);

for (const instance of ['constructor', 'toString', '__proto__', 'standard-5']) {
expect(() => containersBillingIdentity(instance)).toThrow(
`Containers billing is unsupported for instance size: ${instance}`
);
}
});

it('does not classify inherited object keys as containers billing classes', () => {
for (const className of ['toString', 'constructor', 'valueOf', '__proto__'] as const) {
expect(isContainersBillingClassName(className as SandboxClassName)).toBe(false);
}
});

it('classifies exactly the own containers billing classes', () => {
for (const className of ['SandboxContainersStandard3', 'SandboxContainersStandard4'] as const) {
expect(isContainersBillingClassName(className)).toBe(true);
}
for (const className of [
'Sandbox',
'SandboxContainment',
'SandboxSmall',
'SandboxSmallContainment',
'SandboxDIND',
'SandboxCodeReview',
'SandboxCodeReviewContainment',
] as const) {
expect(isContainersBillingClassName(className)).toBe(false);
}
});

it('agrees with containers identity resolution for every resolved class name', () => {
for (const instance of ['standard-3', 'standard-4'] as const) {
const { className } = containersBillingIdentity(instance);
expect(isContainersBillingClassName(className)).toBe(true);
}
});

it('never resolves an inherited object key as a sandbox billing capacity', () => {
for (const className of ['toString', 'constructor', 'valueOf'] as const) {
expect(isContainersBillingClassName(className as SandboxClassName)).toBe(false);
expect(billingCapacityForSandboxClass(className as SandboxClassName)).not.toHaveProperty(
'vcpu'
);
}
});

it('accepts a containers class against an isolated `ses` billing ID and rejects a bare ID', () => {
const containersClasses = ['SandboxContainersStandard3', 'SandboxContainersStandard4'] as const;
const attribution = {
subject: { type: 'user', id: 'user_containers' },
actor: { type: 'user', id: 'user_containers' },
sessionId: 'agent_1',
metadata: { origin: 'cloud-agent' },
} as const;

for (const sandboxClassName of containersClasses) {
expect(() =>
assertSandboxBillingAllocation(sandboxClassName, {
sandboxId: 'ses-abcdef',
...attribution,
})
).not.toThrow();

for (const sandboxId of ['abcdef' as SandboxId, 'org-abcdef' as SandboxId]) {
expect(() =>
assertSandboxBillingAllocation(sandboxClassName, { sandboxId, ...attribution })
).toThrow(`${sandboxClassName} billing received an incompatible sandbox ID`);
}
}
});

it('snapshots the configured capacity for every legacy sandbox class', () => {
expect(SANDBOX_CAPACITIES).toEqual({
Sandbox: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
SandboxContainment: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
Expand Down
84 changes: 75 additions & 9 deletions services/cloud-agent-next/src/container-usage-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type UsageContext,
} from '@kilocode/container-usage';
import { z } from 'zod';
import type { CloudflareContainersInstance } from '@kilocode/worker-utils/sandbox-allocation';
import { logger } from './logger.js';
import { classifySandboxId, isIsolatedSandboxId, isValidSandboxId } from './sandbox-id.js';
import type { SessionMetadata } from './persistence/session-metadata.js';
Expand All @@ -20,17 +21,24 @@ export const SANDBOX_USAGE_SKUS = {
SandboxDIND: 'cloud-agent-dind-2026-07',
SandboxCodeReview: 'cloud-agent-code-review-2026-07',
SandboxCodeReviewContainment: 'cloud-agent-code-review-2026-07',
SandboxContainersStandard3: 'cloud-agent-containers-standard-3-2026-09',
SandboxContainersStandard4: 'cloud-agent-containers-standard-4-2026-09',
} as const;

export type SandboxClassName = keyof typeof SANDBOX_USAGE_SKUS;

export type SandboxCapacity = { vcpu: number; memoryMiB: number; diskMB: number };

export type ContainersBillingClassName =
| 'SandboxContainersStandard3'
| 'SandboxContainersStandard4';

export type LegacySandboxClassName = Exclude<SandboxClassName, ContainersBillingClassName>;

// Production values mirror this service's top-level wrangler.jsonc entries and
// apps/web/src/lib/cloudflare/container-capacity.ts. The parity test reads all three sources.
// Development intentionally uses different named instance types and does not query Analytics.
export const SANDBOX_CAPACITIES: Record<
SandboxClassName,
{ vcpu: number; memoryMiB: number; diskMB: number }
> = {
export const SANDBOX_CAPACITIES: Record<LegacySandboxClassName, SandboxCapacity> = {
Sandbox: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
SandboxContainment: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
SandboxSmall: { vcpu: 2, memoryMiB: 6_144, diskMB: 10_000 },
Expand All @@ -39,6 +47,62 @@ export const SANDBOX_CAPACITIES: Record<
SandboxCodeReview: { vcpu: 1, memoryMiB: 4_096, diskMB: 8_000 },
SandboxCodeReviewContainment: { vcpu: 1, memoryMiB: 4_096, diskMB: 8_000 },
};

// One `SandboxContainers` Durable Object serves every instance size, so there is no per-size
// wrangler class; these billing classes carry the instance-keyed metering capacity.
export const CONTAINERS_BILLING_CAPACITIES: Record<ContainersBillingClassName, SandboxCapacity> = {
SandboxContainersStandard3: { vcpu: 2, memoryMiB: 8_192, diskMB: 16_000 },
SandboxContainersStandard4: { vcpu: 4, memoryMiB: 12_288, diskMB: 20_000 },
};

const USAGE_SERVICE_ROOT = 'cloud-agent-next';

export function usageServiceForSandboxClass(sandboxClassName: SandboxClassName): string {
const suffix = sandboxClassName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
return `${USAGE_SERVICE_ROOT}-${suffix}`;
}

export function isContainersBillingClassName(
sandboxClassName: SandboxClassName
): sandboxClassName is ContainersBillingClassName {
return Object.hasOwn(CONTAINERS_BILLING_CAPACITIES, sandboxClassName);
}

export function billingCapacityForSandboxClass(
sandboxClassName: SandboxClassName
): SandboxCapacity {
return isContainersBillingClassName(sandboxClassName)
? CONTAINERS_BILLING_CAPACITIES[sandboxClassName]
: SANDBOX_CAPACITIES[sandboxClassName];
}

export type ContainersBillingIdentity = {
className: ContainersBillingClassName;
service: string;
sku: string;
capacity: SandboxCapacity;
};

const CONTAINERS_CLASS_BY_INSTANCE: Record<string, ContainersBillingClassName | undefined> = {
'standard-3': 'SandboxContainersStandard3',
'standard-4': 'SandboxContainersStandard4',
} satisfies Record<CloudflareContainersInstance, ContainersBillingClassName>;

export function containersBillingIdentity(instance: string): ContainersBillingIdentity {
const className = Object.hasOwn(CONTAINERS_CLASS_BY_INSTANCE, instance)
? CONTAINERS_CLASS_BY_INSTANCE[instance]
: undefined;
if (className === undefined) {
throw new Error(`Containers billing is unsupported for instance size: ${instance}`);
}
return {
className,
service: usageServiceForSandboxClass(className),
sku: SANDBOX_USAGE_SKUS[className],
capacity: CONTAINERS_BILLING_CAPACITIES[className],
};
}

export type SandboxBillingInput = Omit<UsageContext, 'service' | 'instanceId' | 'sku'> & {
sandboxId: SandboxId;
enforcementRequested?: boolean;
Expand Down Expand Up @@ -145,7 +209,7 @@ export function parseSandboxBillingInput(input: unknown): SandboxBillingInput {
const parsed = sandboxBillingInputEnvelopeSchema.parse(input);
const { sandboxId, enforcementRequested, ...usageInput } = parsed;
const validated = usageContextSchema.parse({
service: 'cloud-agent-next',
service: USAGE_SERVICE_ROOT,
instanceId: 'validation',
sku: 'validation',
...usageInput,
Expand Down Expand Up @@ -179,10 +243,12 @@ export function assertSandboxBillingAllocation(

const expectedSandboxIdClass = standardClass
? 'isolated-standard'
: sandboxClassName === 'SandboxDIND'
? 'devcontainer'
: sandboxClassName === 'SandboxSmall' || sandboxClassName === 'SandboxSmallContainment'
? 'isolated-small'
: isContainersBillingClassName(sandboxClassName) ||
sandboxClassName === 'SandboxSmall' ||
sandboxClassName === 'SandboxSmallContainment'
? 'isolated-small'
: sandboxClassName === 'SandboxDIND'
? 'devcontainer'
: 'code-review';
if (sandboxIdClass !== expectedSandboxIdClass) {
throw new Error(`${sandboxClassName} billing received an incompatible sandbox ID`);
Expand Down
7 changes: 5 additions & 2 deletions services/cloud-agent-next/src/container-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import {
} from '@kilocode/container-usage';
import { Sandbox as StockSandbox } from '@cloudflare/sandbox';
import type { Env } from './types.js';
import type { SandboxBillingAdmissionResult, SandboxClassName } from './container-usage-context.js';
import {
MeteredBillingLifecycle,
usageServiceForSandboxClass,
type SandboxBillingAdmissionResult,
type SandboxClassName,
} from './container-usage-context.js';
import {
MeteredBillingLifecycle,
type BillingIdentity,
type ContainerStopParams,
} from './metered-billing-lifecycle.js';
Expand Down
Loading