270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
import {
|
|
CONSUMING_ALLOCATION_STATUSES,
|
|
GPU_SOCKETS,
|
|
GUARANTEE_TYPES,
|
|
INTERCONNECT_TYPES,
|
|
SECURITY_TIERS,
|
|
} from '@pig/core';
|
|
import type { Allocation, CapacityCommitment, Database } from '@pig/db';
|
|
import { Hono } from 'hono';
|
|
import { z } from 'zod';
|
|
import type { ApiEnv } from '../lib/mutation';
|
|
import type { MutationDefinition } from '../lib/mutation';
|
|
import { MutationError, mutation } from '../lib/mutation';
|
|
import {
|
|
CapacityWriteService,
|
|
type CapacityWriteTransaction,
|
|
} from '../services/capacity-writes';
|
|
|
|
const uuid = z.string().uuid();
|
|
const cents = z.number().int().nonnegative();
|
|
const percentage = z.number().min(0).max(100);
|
|
const gpuHours = z.number().positive().refine(
|
|
(value) => Math.abs(value * 100 - Math.round(value * 100)) < 1e-7,
|
|
'GPU-hours may have at most two decimal places.',
|
|
);
|
|
const currency = z.string().regex(/^[A-Z]{3}$/);
|
|
const shape = z
|
|
.object({
|
|
intervals: z.array(z.string().datetime()).min(2),
|
|
quantities: z.array(z.number().int().nonnegative()).min(1),
|
|
})
|
|
.strict();
|
|
|
|
const commitmentFields = {
|
|
accountId: uuid,
|
|
siteId: uuid.nullable().optional(),
|
|
supplyDealId: uuid.nullable().optional(),
|
|
name: z.string().trim().min(1).max(200),
|
|
gpuType: z.string().trim().min(1).max(100),
|
|
socket: z.enum(GPU_SOCKETS).nullable().optional(),
|
|
gpuCount: z.number().int().positive(),
|
|
interconnectType: z.enum(INTERCONNECT_TYPES).optional(),
|
|
securityTier: z.enum(SECURITY_TIERS).optional(),
|
|
startsAt: z.string().datetime(),
|
|
endsAt: z.string().datetime(),
|
|
totalGpuHours: gpuHours,
|
|
costPerGpuHourCents: cents,
|
|
currency: currency.optional(),
|
|
shape: shape.nullable().optional(),
|
|
colocateWith: z.array(uuid).max(100).optional(),
|
|
isContiguous: z.boolean().optional(),
|
|
minimumSpendCents: cents.nullable().optional(),
|
|
isAutoRenew: z.boolean().optional(),
|
|
noticeDays: z.number().int().nonnegative().nullable().optional(),
|
|
takeOrPayFloorPct: percentage.nullable().optional(),
|
|
prepaidPct: percentage.nullable().optional(),
|
|
prepaidAmountCents: cents.nullable().optional(),
|
|
usefulLifeYears: z.number().positive().max(100).nullable().optional(),
|
|
salvageValuePct: percentage.nullable().optional(),
|
|
depreciationStartAt: z.string().datetime().nullable().optional(),
|
|
costOfCapitalBps: z.number().int().nonnegative().nullable().optional(),
|
|
financingInstrument: z.string().trim().min(1).max(200).nullable().optional(),
|
|
oversubscriptionPct: z.number().min(0).max(1000).optional(),
|
|
notes: z.string().max(10_000).nullable().optional(),
|
|
};
|
|
|
|
const createCommitmentSchema = z.object(commitmentFields).strict();
|
|
const updateCommitmentSchema = z
|
|
.object({
|
|
...commitmentFields,
|
|
terminatedAt: z.string().datetime().nullable().optional(),
|
|
})
|
|
.partial()
|
|
.strict()
|
|
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
|
|
|
|
const allocationFields = {
|
|
capacityCommitmentId: uuid,
|
|
demandDealId: uuid,
|
|
gpuHours,
|
|
pricePerGpuHourCents: cents,
|
|
currency: currency.optional(),
|
|
startsAt: z.string().datetime(),
|
|
endsAt: z.string().datetime(),
|
|
guaranteeType: z.enum(GUARANTEE_TYPES).optional(),
|
|
priority: z.number().int().nonnegative().optional(),
|
|
complianceDecisionId: uuid.nullable().optional(),
|
|
notes: z.string().max(10_000).nullable().optional(),
|
|
};
|
|
|
|
const createAllocationSchema = z
|
|
.object({
|
|
...allocationFields,
|
|
status: z.enum(CONSUMING_ALLOCATION_STATUSES),
|
|
})
|
|
.strict();
|
|
const createHoldSchema = z
|
|
.object({
|
|
...allocationFields,
|
|
pricePerGpuHourCents: cents.optional(),
|
|
holdExpiresAt: z.string().datetime(),
|
|
holdOpportunityCostCents: cents.nullable().optional(),
|
|
})
|
|
.strict();
|
|
const releaseSchema = z
|
|
.object({ reason: z.string().trim().min(1).max(1_000).optional() })
|
|
.strict();
|
|
|
|
type CapacityWriteOperations = Pick<
|
|
CapacityWriteService,
|
|
| 'createCommitment'
|
|
| 'updateCommitment'
|
|
| 'createAllocation'
|
|
| 'createHold'
|
|
| 'releaseAllocation'
|
|
>;
|
|
type ServiceFactory = (tx: CapacityWriteTransaction) => CapacityWriteOperations;
|
|
const service: ServiceFactory = (tx) => new CapacityWriteService(tx);
|
|
|
|
export function createCommitmentMutationDefinition(
|
|
makeService: ServiceFactory = service,
|
|
): MutationDefinition<typeof createCommitmentSchema, CapacityCommitment> {
|
|
return {
|
|
schema: createCommitmentSchema,
|
|
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
|
|
invalidMessage: 'Invalid capacity commitment.',
|
|
async mutate({ input, tx, now }) {
|
|
const result = await makeService(tx).createCommitment(input, now);
|
|
return {
|
|
data: result.commitment,
|
|
activity: {
|
|
type: 'note' as const,
|
|
subject: `Created capacity commitment: ${result.commitment.name}`,
|
|
accountId: result.commitment.accountId,
|
|
supplyDealId: result.commitment.supplyDealId ?? undefined,
|
|
meta: { capacityCommitmentId: result.commitment.id },
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function updateCommitmentMutationDefinition(
|
|
makeService: ServiceFactory = service,
|
|
): MutationDefinition<typeof updateCommitmentSchema, CapacityCommitment> {
|
|
return {
|
|
schema: updateCommitmentSchema,
|
|
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
|
|
invalidMessage: 'Invalid capacity commitment update.',
|
|
async mutate({ input, params, tx, now }) {
|
|
if (!params.id) throw MutationError.notFound('Capacity commitment');
|
|
const result = await makeService(tx).updateCommitment(params.id, input, now);
|
|
return {
|
|
data: result.commitment,
|
|
activity: {
|
|
type: 'note' as const,
|
|
subject: `Updated capacity commitment: ${result.commitment.name}`,
|
|
accountId: result.commitment.accountId,
|
|
supplyDealId: result.commitment.supplyDealId ?? undefined,
|
|
meta: {
|
|
capacityCommitmentId: result.commitment.id,
|
|
changedFields: Object.keys(input),
|
|
...(input.terminatedAt !== undefined
|
|
? { terminatedAt: result.commitment.terminatedAt?.toISOString() ?? null }
|
|
: {}),
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createAllocationMutationDefinition(
|
|
makeService: ServiceFactory = service,
|
|
): MutationDefinition<typeof createAllocationSchema, Allocation> {
|
|
return {
|
|
schema: createAllocationSchema,
|
|
permission: { capability: 'deal:write' as const, team: 'demand' as const },
|
|
invalidMessage: 'Invalid allocation.',
|
|
async mutate({ input, principal, tx, now }) {
|
|
const result = await makeService(tx).createAllocation(input, principal, now);
|
|
return {
|
|
data: result.allocation,
|
|
activity: {
|
|
type: 'note' as const,
|
|
subject: `Allocated ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
|
|
accountId: result.deal.accountId,
|
|
demandDealId: result.deal.id,
|
|
meta: {
|
|
allocationId: result.allocation.id,
|
|
capacityCommitmentId: result.commitment.id,
|
|
status: result.allocation.status,
|
|
pricePerGpuHourCents: result.allocation.pricePerGpuHourCents,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createHoldMutationDefinition(
|
|
makeService: ServiceFactory = service,
|
|
): MutationDefinition<typeof createHoldSchema, Allocation> {
|
|
return {
|
|
schema: createHoldSchema,
|
|
permission: { capability: 'deal:write' as const, team: 'demand' as const },
|
|
invalidMessage: 'Invalid capacity hold.',
|
|
async mutate({ input, principal, tx, now }) {
|
|
const result = await makeService(tx).createHold(input, principal, now);
|
|
return {
|
|
data: result.allocation,
|
|
activity: {
|
|
type: 'note' as const,
|
|
subject: `Held ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
|
|
accountId: result.deal.accountId,
|
|
demandDealId: result.deal.id,
|
|
meta: {
|
|
allocationId: result.allocation.id,
|
|
capacityCommitmentId: result.commitment.id,
|
|
holdExpiresAt: input.holdExpiresAt,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function releaseAllocationMutationDefinition(
|
|
makeService: ServiceFactory = service,
|
|
): MutationDefinition<typeof releaseSchema, Allocation> {
|
|
return {
|
|
schema: releaseSchema,
|
|
permission: { capability: 'deal:write' as const, team: 'demand' as const },
|
|
invalidMessage: 'Invalid allocation release.',
|
|
async mutate({ input, params, tx, now }) {
|
|
if (!params.id) throw MutationError.notFound('Allocation');
|
|
const result = await makeService(tx).releaseAllocation(params.id, input, now);
|
|
return {
|
|
data: result.allocation,
|
|
activity: {
|
|
type: 'note' as const,
|
|
subject: `Released allocation ${result.allocation.id}`,
|
|
accountId: result.accountId,
|
|
demandDealId: result.allocation.demandDealId ?? undefined,
|
|
meta: {
|
|
allocationId: result.allocation.id,
|
|
capacityCommitmentId: result.allocation.capacityCommitmentId,
|
|
from: result.previousStatus,
|
|
to: 'released',
|
|
reason: input.reason,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createCapacityWriteRoutes(db: Database): Hono<ApiEnv> {
|
|
const routes = new Hono<ApiEnv>();
|
|
routes.post('/api/commitments', mutation(db, createCommitmentMutationDefinition()));
|
|
routes.patch('/api/commitments/:id', mutation(db, updateCommitmentMutationDefinition()));
|
|
routes.post('/api/allocations', mutation(db, createAllocationMutationDefinition()));
|
|
routes.post('/api/allocations/holds', mutation(db, createHoldMutationDefinition()));
|
|
routes.post(
|
|
'/api/allocations/:id/release',
|
|
mutation(db, releaseAllocationMutationDefinition()),
|
|
);
|
|
return routes;
|
|
}
|