feat: complete TASK-WP3-02 runtime recommendation
This commit is contained in:
@@ -55,7 +55,7 @@ export interface ModelConfigView extends ModelConfigCandidate {
|
|||||||
export interface ModelConfigurationView {
|
export interface ModelConfigurationView {
|
||||||
config_set_version: number;
|
config_set_version: number;
|
||||||
configured_default_model_id: ModelId;
|
configured_default_model_id: ModelId;
|
||||||
recommended_model_id: null;
|
recommended_model_id: ModelId | null;
|
||||||
models: ModelConfigView[];
|
models: ModelConfigView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,10 +258,13 @@ export class ModelConfigurationService {
|
|||||||
reason: row.reason,
|
reason: row.reason,
|
||||||
},
|
},
|
||||||
} satisfies ModelConfigView));
|
} satisfies ModelConfigView));
|
||||||
|
const recommended = models.find((model) => model.enabled
|
||||||
|
&& model.contract_validation_status === "verified"
|
||||||
|
&& model.runtime_availability.available_for_new_jobs);
|
||||||
return {
|
return {
|
||||||
config_set_version: current.config_set_version,
|
config_set_version: current.config_set_version,
|
||||||
configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId,
|
configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId,
|
||||||
recommended_model_id: null,
|
recommended_model_id: recommended?.model_id ?? null,
|
||||||
models,
|
models,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||||
|
|
||||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||||
|
|
||||||
@@ -13,45 +13,13 @@ export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, opti
|
|||||||
return response.json() as Promise<CreditAdjustmentResponse>;
|
return response.json() as Promise<CreditAdjustmentResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkBrowserSupport(body: {
|
export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> {
|
||||||
"brands": Array<{
|
|
||||||
"brand": string;
|
|
||||||
"version": string;
|
|
||||||
}>;
|
|
||||||
"full_version_list": Array<{
|
|
||||||
"brand": string;
|
|
||||||
"version": string;
|
|
||||||
}>;
|
|
||||||
"platform": string;
|
|
||||||
}, options: ClientOptions = {}): Promise<{
|
|
||||||
"app_version": string;
|
|
||||||
"browser": {
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
};
|
|
||||||
"status": "supported";
|
|
||||||
"supported_browsers": Array<{
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<BrowserSupportSuccess>;
|
||||||
"app_version": string;
|
|
||||||
"browser": {
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
};
|
|
||||||
"status": "supported";
|
|
||||||
"supported_browsers": Array<{
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
|
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
|
||||||
@@ -133,43 +101,11 @@ export async function getAdminUserCredits(options: ClientOptions = {}): Promise<
|
|||||||
return response.json() as Promise<CreditBalanceResponse>;
|
return response.json() as Promise<CreditBalanceResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
export async function getBootstrap(options: ClientOptions = {}): Promise<BootstrapResponse> {
|
||||||
"app_version": string;
|
|
||||||
"dependencies": Array<{
|
|
||||||
"service_id": string;
|
|
||||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
|
||||||
}>;
|
|
||||||
"model_summary": {
|
|
||||||
"config_set_version": number | null;
|
|
||||||
"configured_default_model_id": string | null;
|
|
||||||
"recommended_model_id": string | null;
|
|
||||||
"runtime_availability_version": number | null;
|
|
||||||
};
|
|
||||||
"public_features": Array<{
|
|
||||||
"feature_id": string;
|
|
||||||
"status": "enabled" | "disabled" | "paused";
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<BootstrapResponse>;
|
||||||
"app_version": string;
|
|
||||||
"dependencies": Array<{
|
|
||||||
"service_id": string;
|
|
||||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
|
||||||
}>;
|
|
||||||
"model_summary": {
|
|
||||||
"config_set_version": number | null;
|
|
||||||
"configured_default_model_id": string | null;
|
|
||||||
"recommended_model_id": string | null;
|
|
||||||
"runtime_availability_version": number | null;
|
|
||||||
};
|
|
||||||
"public_features": Array<{
|
|
||||||
"feature_id": string;
|
|
||||||
"status": "enabled" | "disabled" | "paused";
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
export async function getCurrentGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||||
@@ -190,64 +126,18 @@ export async function getGeneration(options: ClientOptions = {}): Promise<Genera
|
|||||||
return response.json() as Promise<GenerationTaskResponse>;
|
return response.json() as Promise<GenerationTaskResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getModel(options: ClientOptions = {}): Promise<{
|
export async function getModel(options: ClientOptions = {}): Promise<ModelConfig> {
|
||||||
"config_version": number;
|
|
||||||
"contract_evidence_ref": string | null;
|
|
||||||
"contract_validation_status": ModelContractValidationStatus;
|
|
||||||
"credit_cost": number;
|
|
||||||
"display_name": string;
|
|
||||||
"enabled": boolean;
|
|
||||||
"error_mapping_profile": Record<string, never>;
|
|
||||||
"gateway_account_ref": string;
|
|
||||||
"is_default": boolean;
|
|
||||||
"model_id": ModelId;
|
|
||||||
"prompt_max_length": number;
|
|
||||||
"recommendation_priority": number;
|
|
||||||
"reference_limits": ModelReferenceLimits;
|
|
||||||
"route_profile": Record<string, never>;
|
|
||||||
"runtime_availability": ModelRuntimeAvailability;
|
|
||||||
"safety_source": string;
|
|
||||||
"supported_ratios": Array<string>;
|
|
||||||
}> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/models/{model_id}`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/models/{model_id}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<ModelConfig>;
|
||||||
"config_version": number;
|
|
||||||
"contract_evidence_ref": string | null;
|
|
||||||
"contract_validation_status": ModelContractValidationStatus;
|
|
||||||
"credit_cost": number;
|
|
||||||
"display_name": string;
|
|
||||||
"enabled": boolean;
|
|
||||||
"error_mapping_profile": Record<string, never>;
|
|
||||||
"gateway_account_ref": string;
|
|
||||||
"is_default": boolean;
|
|
||||||
"model_id": ModelId;
|
|
||||||
"prompt_max_length": number;
|
|
||||||
"recommendation_priority": number;
|
|
||||||
"reference_limits": ModelReferenceLimits;
|
|
||||||
"route_profile": Record<string, never>;
|
|
||||||
"runtime_availability": ModelRuntimeAvailability;
|
|
||||||
"safety_source": string;
|
|
||||||
"supported_ratios": Array<string>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getModels(options: ClientOptions = {}): Promise<{
|
export async function getModels(options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||||
"config_set_version": number;
|
|
||||||
"configured_default_model_id": ModelId;
|
|
||||||
"models": Array<ModelConfig>;
|
|
||||||
"recommended_model_id": ModelId | null;
|
|
||||||
}> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<ModelConfigurationResponse>;
|
||||||
"config_set_version": number;
|
|
||||||
"configured_default_model_id": ModelId;
|
|
||||||
"models": Array<ModelConfig>;
|
|
||||||
"recommended_model_id": ModelId | null;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||||
@@ -308,26 +198,13 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO
|
|||||||
return response.json() as Promise<ProjectRenameResponse>;
|
return response.json() as Promise<ProjectRenameResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceModelConfiguration(body: {
|
export async function replaceModelConfiguration(body: ModelConfigUpdateRequest, options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||||
"expected_config_set_version": number;
|
|
||||||
"models": Array<ModelConfigCandidate>;
|
|
||||||
}, options: ClientOptions = {}): Promise<{
|
|
||||||
"config_set_version": number;
|
|
||||||
"configured_default_model_id": ModelId;
|
|
||||||
"models": Array<ModelConfig>;
|
|
||||||
"recommended_model_id": ModelId | null;
|
|
||||||
}> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/models/configuration`, { body: JSON.stringify(body), method: "PUT", headers });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/models/configuration`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<ModelConfigurationResponse>;
|
||||||
"config_set_version": number;
|
|
||||||
"configured_default_model_id": ModelId;
|
|
||||||
"models": Array<ModelConfig>;
|
|
||||||
"recommended_model_id": ModelId | null;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export type CanvasElement = {
|
|||||||
"longitude": number;
|
"longitude": number;
|
||||||
};
|
};
|
||||||
"created_at": string;
|
"created_at": string;
|
||||||
"dynamic_fields"?: Record<string, never>;
|
"dynamic_fields"?: Record<string, string | number | boolean | null>;
|
||||||
"element_id": string;
|
"element_id": string;
|
||||||
"font_override"?: string;
|
"font_override"?: string;
|
||||||
"font_size"?: number;
|
"font_size"?: number;
|
||||||
@@ -181,7 +181,7 @@ export type CanvasElement = {
|
|||||||
"y": number;
|
"y": number;
|
||||||
};
|
};
|
||||||
"style_id"?: string;
|
"style_id"?: string;
|
||||||
"style_parameters"?: Record<string, never>;
|
"style_parameters"?: Record<string, string | number | boolean | null>;
|
||||||
"template_or_asset_id": string;
|
"template_or_asset_id": string;
|
||||||
"type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker";
|
"type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker";
|
||||||
"z_index": number;
|
"z_index": number;
|
||||||
@@ -457,14 +457,14 @@ export type ModelConfig = {
|
|||||||
"credit_cost": number;
|
"credit_cost": number;
|
||||||
"display_name": string;
|
"display_name": string;
|
||||||
"enabled": boolean;
|
"enabled": boolean;
|
||||||
"error_mapping_profile": Record<string, never>;
|
"error_mapping_profile": Record<string, string>;
|
||||||
"gateway_account_ref": string;
|
"gateway_account_ref": string;
|
||||||
"is_default": boolean;
|
"is_default": boolean;
|
||||||
"model_id": ModelId;
|
"model_id": ModelId;
|
||||||
"prompt_max_length": number;
|
"prompt_max_length": number;
|
||||||
"recommendation_priority": number;
|
"recommendation_priority": number;
|
||||||
"reference_limits": ModelReferenceLimits;
|
"reference_limits": ModelReferenceLimits;
|
||||||
"route_profile": Record<string, never>;
|
"route_profile": Record<string, unknown>;
|
||||||
"runtime_availability": ModelRuntimeAvailability;
|
"runtime_availability": ModelRuntimeAvailability;
|
||||||
"safety_source": string;
|
"safety_source": string;
|
||||||
"supported_ratios": Array<string>;
|
"supported_ratios": Array<string>;
|
||||||
@@ -474,14 +474,14 @@ export type ModelConfigCandidate = {
|
|||||||
"credit_cost": number;
|
"credit_cost": number;
|
||||||
"display_name": string;
|
"display_name": string;
|
||||||
"enabled": boolean;
|
"enabled": boolean;
|
||||||
"error_mapping_profile": Record<string, never>;
|
"error_mapping_profile": Record<string, string>;
|
||||||
"gateway_account_ref": string;
|
"gateway_account_ref": string;
|
||||||
"is_default": boolean;
|
"is_default": boolean;
|
||||||
"model_id": ModelId;
|
"model_id": ModelId;
|
||||||
"prompt_max_length": number;
|
"prompt_max_length": number;
|
||||||
"recommendation_priority": number;
|
"recommendation_priority": number;
|
||||||
"reference_limits": ModelReferenceLimits;
|
"reference_limits": ModelReferenceLimits;
|
||||||
"route_profile": Record<string, never>;
|
"route_profile": Record<string, unknown>;
|
||||||
"safety_source": string;
|
"safety_source": string;
|
||||||
"supported_ratios": Array<string>;
|
"supported_ratios": Array<string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,17 +6,28 @@ import type BetterSqlite3 from "better-sqlite3";
|
|||||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||||
|
|
||||||
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||||
|
const recoveryCheckLifetimeMilliseconds = 5 * 60 * 1_000;
|
||||||
const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/;
|
const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/;
|
||||||
const sourceCategoryPattern = /^[a-z][a-z0-9_]{0,79}$/;
|
const sourceCategoryPattern = /^[a-z][a-z0-9_]{0,79}$/;
|
||||||
|
const runtimeReasons = [
|
||||||
|
"available", "configured_disabled", "contract_unverified", "contract_blocked",
|
||||||
|
"gateway_balance_insufficient", "gateway_paused", "worker_degraded",
|
||||||
|
] as const;
|
||||||
|
type RuntimeReason = typeof runtimeReasons[number];
|
||||||
|
|
||||||
interface RuntimeRow {
|
interface RuntimeRow {
|
||||||
available_for_new_jobs: number;
|
available_for_new_jobs: number;
|
||||||
|
checked_at: number;
|
||||||
|
model_id: string;
|
||||||
|
reason: RuntimeReason;
|
||||||
|
runtime_availability_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MappingRow {
|
||||||
balance_blocked: number;
|
balance_blocked: number;
|
||||||
gateway_account_ref: string;
|
gateway_account_ref: string;
|
||||||
model_id: string;
|
model_id: string;
|
||||||
runtime_reason: string;
|
previous_runtime_reason: RuntimeReason | null;
|
||||||
runtime_version: number;
|
|
||||||
updated_at: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BalanceRow {
|
interface BalanceRow {
|
||||||
@@ -43,6 +54,14 @@ export interface GatewayBalanceView {
|
|||||||
sourceCategory: string;
|
sourceCategory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayRecoveryCheck {
|
||||||
|
check_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
status: "passed";
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class GatewayBalanceRuntime {
|
export class GatewayBalanceRuntime {
|
||||||
readonly database: BetterSqlite3.Database;
|
readonly database: BetterSqlite3.Database;
|
||||||
private readonly clock: () => number;
|
private readonly clock: () => number;
|
||||||
@@ -68,10 +87,15 @@ export class GatewayBalanceRuntime {
|
|||||||
if (!accountRefPattern.test(model.gatewayAccountRef) || !model.modelId) throw new Error("gateway_balance_model_invalid");
|
if (!accountRefPattern.test(model.gatewayAccountRef) || !model.modelId) throw new Error("gateway_balance_model_invalid");
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO model_runtime_availability (
|
INSERT INTO model_runtime_availability (
|
||||||
model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at
|
model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version
|
||||||
) VALUES (?, ?, 0, 1, 'available', 1, ?)
|
) VALUES (?, 1, 'available', ?, 1)
|
||||||
ON CONFLICT(model_id) DO UPDATE SET gateway_account_ref = excluded.gateway_account_ref
|
ON CONFLICT(model_id) DO NOTHING
|
||||||
`).run(model.modelId, model.gatewayAccountRef, now);
|
`).run(model.modelId, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||||
|
VALUES (?, ?, 0, NULL)
|
||||||
|
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||||
|
`).run(model.gatewayAccountRef, model.modelId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,26 +113,30 @@ export class GatewayBalanceRuntime {
|
|||||||
const receipt = this.database.prepare("SELECT response_json FROM gateway_balance_event_receipts WHERE event_id = ?")
|
const receipt = this.database.prepare("SELECT response_json FROM gateway_balance_event_receipts WHERE event_id = ?")
|
||||||
.get(input.eventId) as { response_json: string } | undefined;
|
.get(input.eventId) as { response_json: string } | undefined;
|
||||||
if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView;
|
if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView;
|
||||||
this.database.prepare(`
|
|
||||||
INSERT INTO model_runtime_availability (
|
this.ensureModelMapping(input.gatewayAccountRef, input.modelId);
|
||||||
model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at
|
const accountModels = this.database.prepare("SELECT model_id FROM gateway_balance_affected_models WHERE gateway_account_ref = ? ORDER BY model_id")
|
||||||
) VALUES (?, ?, 0, 1, 'available', 1, ?)
|
|
||||||
ON CONFLICT(model_id) DO UPDATE SET gateway_account_ref = excluded.gateway_account_ref
|
|
||||||
`).run(input.modelId, input.gatewayAccountRef, this.clock());
|
|
||||||
const rows = this.database.prepare("SELECT model_id FROM model_runtime_availability WHERE gateway_account_ref = ? ORDER BY model_id")
|
|
||||||
.all(input.gatewayAccountRef) as Array<{ model_id: string }>;
|
.all(input.gatewayAccountRef) as Array<{ model_id: string }>;
|
||||||
const newlyAffected = input.impactScope === "model" ? [input.modelId] : rows.map((row) => row.model_id);
|
const newlyAffected = input.impactScope === "model" ? [input.modelId] : accountModels.map((row) => row.model_id);
|
||||||
const existing = this.database.prepare("SELECT runtime_unavailable_model_ids_json FROM gateway_balance_states WHERE gateway_account_ref = ?")
|
const existing = this.database.prepare("SELECT runtime_unavailable_model_ids_json FROM gateway_balance_states WHERE gateway_account_ref = ?")
|
||||||
.get(input.gatewayAccountRef) as { runtime_unavailable_model_ids_json: string } | undefined;
|
.get(input.gatewayAccountRef) as { runtime_unavailable_model_ids_json: string } | undefined;
|
||||||
const unavailable = [...new Set([...(existing ? JSON.parse(existing.runtime_unavailable_model_ids_json) as string[] : []), ...newlyAffected])].toSorted();
|
const unavailable = [...new Set([...(existing ? JSON.parse(existing.runtime_unavailable_model_ids_json) as string[] : []), ...newlyAffected])].toSorted();
|
||||||
const now = this.clock();
|
const now = this.clock();
|
||||||
for (const modelId of unavailable) {
|
for (const modelId of unavailable) {
|
||||||
this.database.prepare(`
|
const mapping = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
UPDATE model_runtime_availability
|
.get(input.gatewayAccountRef, modelId) as MappingRow | undefined;
|
||||||
SET balance_blocked = 1, available_for_new_jobs = 0, runtime_reason = 'gateway_balance_insufficient',
|
const runtime = this.database.prepare("SELECT * FROM model_runtime_availability WHERE model_id = ?").get(modelId) as RuntimeRow | undefined;
|
||||||
runtime_version = runtime_version + 1, updated_at = ?
|
if (!mapping || !runtime) continue;
|
||||||
WHERE model_id = ? AND gateway_account_ref = ? AND balance_blocked = 0
|
if (mapping.balance_blocked === 0) {
|
||||||
`).run(now, modelId, input.gatewayAccountRef);
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1, previous_runtime_reason = ? WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(runtime.reason, input.gatewayAccountRef, modelId);
|
||||||
|
} else {
|
||||||
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1 WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(input.gatewayAccountRef, modelId);
|
||||||
|
}
|
||||||
|
const reason = runtime.reason === "configured_disabled" || runtime.reason === "contract_unverified" || runtime.reason === "contract_blocked"
|
||||||
|
? runtime.reason : "gateway_balance_insufficient";
|
||||||
|
this.updateRuntime(modelId, false, reason, now);
|
||||||
}
|
}
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO gateway_balance_states (
|
INSERT INTO gateway_balance_states (
|
||||||
@@ -141,24 +169,117 @@ export class GatewayBalanceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listRuntime() {
|
listRuntime() {
|
||||||
const rows = this.database.prepare("SELECT * FROM model_runtime_availability ORDER BY model_id").all() as RuntimeRow[];
|
const rows = this.database.prepare(`
|
||||||
|
SELECT r.*, COALESCE(m.gateway_account_ref, 'unknown') AS gateway_account_ref,
|
||||||
|
COALESCE(m.balance_blocked, 0) AS balance_blocked
|
||||||
|
FROM model_runtime_availability r
|
||||||
|
LEFT JOIN gateway_balance_affected_models m ON m.model_id = r.model_id
|
||||||
|
ORDER BY r.model_id
|
||||||
|
`).all() as Array<RuntimeRow & { balance_blocked: number; gateway_account_ref: string }>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
availableForNewJobs: row.available_for_new_jobs === 1,
|
availableForNewJobs: row.available_for_new_jobs === 1,
|
||||||
balanceBlocked: row.balance_blocked === 1,
|
balanceBlocked: row.balance_blocked === 1,
|
||||||
gatewayAccountRef: row.gateway_account_ref,
|
gatewayAccountRef: row.gateway_account_ref,
|
||||||
modelId: row.model_id,
|
modelId: row.model_id,
|
||||||
runtimeReason: row.runtime_reason,
|
runtimeReason: row.reason,
|
||||||
runtimeVersion: row.runtime_version,
|
runtimeVersion: row.runtime_availability_version,
|
||||||
updatedAt: new Date(row.updated_at).toISOString(),
|
updatedAt: new Date(row.checked_at).toISOString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runRecoveryCheck(gatewayAccountRef: string): GatewayRecoveryCheck {
|
||||||
|
const state = this.readState(gatewayAccountRef);
|
||||||
|
if (!state || state.recoveryStatus !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const now = this.clock();
|
||||||
|
const check = {
|
||||||
|
check_id: randomUUID(), expires_at: new Date(now + recoveryCheckLifetimeMilliseconds).toISOString(),
|
||||||
|
gateway_account_ref: gatewayAccountRef, status: "passed" as const, checked_at: new Date(now).toISOString(),
|
||||||
|
};
|
||||||
|
this.immediate(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO service_recovery_checks (check_id, service_name, target_ref, status, checked_at, expires_at, details_json)
|
||||||
|
VALUES (?, 'gateway_balance', ?, 'passed', ?, ?, ?)
|
||||||
|
`).run(check.check_id, gatewayAccountRef, now, now + recoveryCheckLifetimeMilliseconds, JSON.stringify({ non_sensitive: true }));
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRecovery(input: { actorId: string; gatewayAccountRef: string; recoveryCheckId?: string }) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const state = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(input.gatewayAccountRef) as BalanceRow | undefined;
|
||||||
|
if (!state || state.recovery_status !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const check = input.recoveryCheckId
|
||||||
|
? this.database.prepare("SELECT * FROM service_recovery_checks WHERE check_id = ? AND target_ref = ? AND service_name = 'gateway_balance'").get(input.recoveryCheckId, input.gatewayAccountRef) as { expires_at: number; status: string } | undefined
|
||||||
|
: undefined;
|
||||||
|
if (!check || check.status !== "passed" || check.expires_at < this.clock()) throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const now = this.clock();
|
||||||
|
const rows = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND balance_blocked = 1")
|
||||||
|
.all(input.gatewayAccountRef) as MappingRow[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const configuredReason = this.configuredRuntimeReason(row.model_id);
|
||||||
|
const reason = configuredReason === "available" ? (row.previous_runtime_reason ?? "available") : configuredReason;
|
||||||
|
this.updateRuntime(row.model_id, reason === "available", reason, now);
|
||||||
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 0, previous_runtime_reason = NULL WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(input.gatewayAccountRef, row.model_id);
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE gateway_balance_states SET balance_status = 'available', recovery_status = 'confirmed', last_confirmed_at = ?
|
||||||
|
WHERE gateway_account_ref = ?
|
||||||
|
`).run(now, input.gatewayAccountRef);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, 'gateway_balance_recovery', 'gateway_balance_state', ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), input.actorId, input.gatewayAccountRef,
|
||||||
|
JSON.stringify({ recovery_status: "awaiting_confirmation" }),
|
||||||
|
JSON.stringify({ recovery_status: "confirmed", restored_model_count: rows.length }), now, now + auditRetentionMilliseconds);
|
||||||
|
return this.readState(input.gatewayAccountRef)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
restoreWithoutConfirmedRecovery(gatewayAccountRef: string, _actorId: string) {
|
restoreWithoutConfirmedRecovery(gatewayAccountRef: string, _actorId: string) {
|
||||||
const state = this.readState(gatewayAccountRef);
|
const state = this.readState(gatewayAccountRef);
|
||||||
if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed");
|
if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
throw new Error("gateway_balance_recovery_owned_by_wp3");
|
throw new Error("gateway_balance_recovery_owned_by_wp3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureModelMapping(gatewayAccountRef: string, modelId: string) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
VALUES (?, 1, 'available', ?, 1)
|
||||||
|
ON CONFLICT(model_id) DO NOTHING
|
||||||
|
`).run(modelId, this.clock());
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||||
|
VALUES (?, ?, 0, NULL)
|
||||||
|
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||||
|
`).run(gatewayAccountRef, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRuntime(modelId: string, available: boolean, reason: RuntimeReason, now: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE model_runtime_availability
|
||||||
|
SET available_for_new_jobs = ?, reason = ?, checked_at = ?, runtime_availability_version = runtime_availability_version + 1
|
||||||
|
WHERE model_id = ?
|
||||||
|
`).run(available ? 1 : 0, reason, now, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private configuredRuntimeReason(modelId: string): RuntimeReason {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT m.enabled, v.contract_validation_status
|
||||||
|
FROM model_config_current c
|
||||||
|
JOIN model_config_set_members m ON m.config_set_id = c.config_set_id AND c.singleton = 1
|
||||||
|
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
|
||||||
|
WHERE m.model_id = ?
|
||||||
|
`).get(modelId) as { contract_validation_status: "blocked" | "unverified" | "verified"; enabled: number } | undefined;
|
||||||
|
if (!row) return "available";
|
||||||
|
if (row.enabled !== 1) return "configured_disabled";
|
||||||
|
if (row.contract_validation_status === "blocked") return "contract_blocked";
|
||||||
|
if (row.contract_validation_status !== "verified") return "contract_unverified";
|
||||||
|
return "available";
|
||||||
|
}
|
||||||
|
|
||||||
private view(row: BalanceRow): GatewayBalanceView {
|
private view(row: BalanceRow): GatewayBalanceView {
|
||||||
return {
|
return {
|
||||||
affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[],
|
affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[],
|
||||||
@@ -187,18 +308,39 @@ export class GatewayBalanceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private migrate() {
|
private migrate() {
|
||||||
|
const existingRuntimeColumns = new Set((this.database.prepare("PRAGMA table_info(model_runtime_availability)").all() as Array<{ name: string }>).map((column) => column.name));
|
||||||
|
if (existingRuntimeColumns.has("gateway_account_ref")) {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE model_runtime_availability_v2 (
|
||||||
|
model_id TEXT PRIMARY KEY,
|
||||||
|
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||||
|
);
|
||||||
|
INSERT INTO model_runtime_availability_v2 (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
SELECT model_id, available_for_new_jobs, runtime_reason, updated_at, runtime_version FROM model_runtime_availability;
|
||||||
|
DROP TABLE model_runtime_availability;
|
||||||
|
ALTER TABLE model_runtime_availability_v2 RENAME TO model_runtime_availability;
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||||
|
model_id TEXT PRIMARY KEY,
|
||||||
|
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
this.database.exec(`
|
this.database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
CREATE TABLE IF NOT EXISTS gateway_balance_affected_models (
|
||||||
model_id TEXT PRIMARY KEY,
|
|
||||||
gateway_account_ref TEXT NOT NULL,
|
gateway_account_ref TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
balance_blocked INTEGER NOT NULL CHECK (balance_blocked IN (0, 1)),
|
balance_blocked INTEGER NOT NULL CHECK (balance_blocked IN (0, 1)),
|
||||||
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
previous_runtime_reason TEXT CHECK (previous_runtime_reason IS NULL OR previous_runtime_reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
runtime_reason TEXT NOT NULL CHECK (runtime_reason IN (
|
PRIMARY KEY (gateway_account_ref, model_id)
|
||||||
'available', 'configured_disabled', 'contract_unverified', 'contract_blocked',
|
|
||||||
'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded'
|
|
||||||
)),
|
|
||||||
runtime_version INTEGER NOT NULL CHECK (runtime_version > 0),
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gateway_balance_states (
|
CREATE TABLE IF NOT EXISTS gateway_balance_states (
|
||||||
gateway_account_ref TEXT PRIMARY KEY,
|
gateway_account_ref TEXT PRIMARY KEY,
|
||||||
@@ -217,25 +359,32 @@ export class GatewayBalanceRuntime {
|
|||||||
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS service_recovery_checks (
|
||||||
|
check_id TEXT PRIMARY KEY,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('passed', 'failed')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
details_json TEXT NOT NULL CHECK (json_valid(details_json))
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
log_id TEXT PRIMARY KEY,
|
log_id TEXT PRIMARY KEY,
|
||||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1),
|
actor_ref TEXT NOT NULL,
|
||||||
operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1),
|
operation_type TEXT NOT NULL,
|
||||||
target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1),
|
target_type TEXT NOT NULL,
|
||||||
target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1),
|
target_ref TEXT NOT NULL,
|
||||||
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1),
|
before_summary TEXT,
|
||||||
after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1),
|
after_summary TEXT,
|
||||||
occurred_at INTEGER NOT NULL,
|
occurred_at INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
expires_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
BEFORE DELETE ON admin_operation_logs
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
|
||||||
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -67,7 +67,9 @@
|
|||||||
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
||||||
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red",
|
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red",
|
||||||
"test:wp3-01": "node scripts/run-wp3-01-validation.mjs",
|
"test:wp3-01": "node scripts/run-wp3-01-validation.mjs",
|
||||||
"test:wp3-01:red": "node scripts/run-wp3-01-validation.mjs --phase red"
|
"test:wp3-01:red": "node scripts/run-wp3-01-validation.mjs --phase red",
|
||||||
|
"test:wp3-02": "node scripts/run-wp3-02-validation.mjs",
|
||||||
|
"test:wp3-02:red": "node scripts/run-wp3-02-validation.mjs --phase red"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ function identifier(value) {
|
|||||||
return value.replaceAll(/[^A-Za-z0-9_$]/g, "_");
|
return value.replaceAll(/[^A-Za-z0-9_$]/g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaType(schema) {
|
function schemaType(schema, namedSchemas = []) {
|
||||||
if (!schema || typeof schema !== "object") return "unknown";
|
if (!schema || typeof schema !== "object") return "unknown";
|
||||||
if (schema.$ref) return identifier(schema.$ref.split("/").at(-1));
|
if (schema.$ref) return identifier(schema.$ref.split("/").at(-1));
|
||||||
|
const named = namedSchemas.find(([, candidate]) => JSON.stringify(candidate) === JSON.stringify(schema));
|
||||||
|
if (named) return identifier(named[0]);
|
||||||
if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const);
|
if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const);
|
||||||
if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
||||||
if (schema.anyOf) return schema.anyOf.map(schemaType).join(" | ");
|
if (schema.anyOf) return schema.anyOf.map((item) => schemaType(item, namedSchemas)).join(" | ");
|
||||||
if (schema.oneOf) return schema.oneOf.map(schemaType).join(" | ");
|
if (schema.oneOf) return schema.oneOf.map((item) => schemaType(item, namedSchemas)).join(" | ");
|
||||||
if (Array.isArray(schema.type)) {
|
if (Array.isArray(schema.type)) {
|
||||||
return schema.type.map((type) => schemaType({ ...schema, type })).join(" | ");
|
return schema.type.map((type) => schemaType({ ...schema, type }, namedSchemas)).join(" | ");
|
||||||
}
|
}
|
||||||
if (schema.type === "array") return `Array<${schemaType(schema.items)}>`;
|
if (schema.type === "array") return `Array<${schemaType(schema.items, namedSchemas)}>`;
|
||||||
if (schema.type === "boolean") return "boolean";
|
if (schema.type === "boolean") return "boolean";
|
||||||
if (schema.type === "integer" || schema.type === "number") return "number";
|
if (schema.type === "integer" || schema.type === "number") return "number";
|
||||||
if (schema.type === "null") return "null";
|
if (schema.type === "null") return "null";
|
||||||
@@ -23,27 +25,31 @@ function schemaType(schema) {
|
|||||||
if (schema.type === "object" || schema.properties) {
|
if (schema.type === "object" || schema.properties) {
|
||||||
const required = new Set(schema.required ?? []);
|
const required = new Set(schema.required ?? []);
|
||||||
const fields = Object.entries(schema.properties ?? {}).map(
|
const fields = Object.entries(schema.properties ?? {}).map(
|
||||||
([name, child]) => ` ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${schemaType(child)};`,
|
([name, child]) => ` ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${schemaType(child, namedSchemas)};`,
|
||||||
);
|
);
|
||||||
|
if (!fields.length && schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||||
|
return `Record<string, ${schemaType(schema.additionalProperties, namedSchemas)}>`;
|
||||||
|
}
|
||||||
|
if (!fields.length && schema.additionalProperties === true) return "Record<string, unknown>";
|
||||||
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function operationResult(operation) {
|
function operationResult(operation, namedSchemas) {
|
||||||
const response = operation.responses?.["200"];
|
const response = operation.responses?.["200"];
|
||||||
if (!response) return "unknown";
|
if (!response) return "unknown";
|
||||||
if (response.content) {
|
if (response.content) {
|
||||||
const media = response.content["application/json"] ?? response.content["text/event-stream"] ?? Object.values(response.content)[0];
|
const media = response.content["application/json"] ?? response.content["text/event-stream"] ?? Object.values(response.content)[0];
|
||||||
if (media?.schema) return media.schema.format === "binary" ? "Blob" : schemaType(media.schema);
|
if (media?.schema) return media.schema.format === "binary" ? "Blob" : schemaType(media.schema, namedSchemas);
|
||||||
}
|
}
|
||||||
return response.schema ? schemaType(response.schema) : "unknown";
|
return response.schema ? schemaType(response.schema, namedSchemas) : "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function operationBodyType(operation) {
|
function operationBodyType(operation, namedSchemas) {
|
||||||
const content = operation.requestBody?.content;
|
const content = operation.requestBody?.content;
|
||||||
const jsonSchema = content?.["application/json"]?.schema;
|
const jsonSchema = content?.["application/json"]?.schema;
|
||||||
if (jsonSchema) return schemaType(jsonSchema);
|
if (jsonSchema) return schemaType(jsonSchema, namedSchemas);
|
||||||
if (content?.["multipart/form-data"]?.schema) return "FormData";
|
if (content?.["multipart/form-data"]?.schema) return "FormData";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -90,16 +96,16 @@ export function generateClient(input, output) {
|
|||||||
const operationList = operations(document);
|
const operationList = operations(document);
|
||||||
const builtInTypes = new Set(["Blob", "FormData", "boolean", "number", "string", "unknown"]);
|
const builtInTypes = new Set(["Blob", "FormData", "boolean", "number", "string", "unknown"]);
|
||||||
const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [
|
const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [
|
||||||
operationResult(operation),
|
operationResult(operation, schemas),
|
||||||
operationBodyType(operation),
|
operationBodyType(operation, schemas),
|
||||||
]).filter((type) => type && !builtInTypes.has(type) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))];
|
]).filter((type) => type && !builtInTypes.has(type) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))];
|
||||||
const sdk = [
|
const sdk = [
|
||||||
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
||||||
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
||||||
"export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }",
|
"export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }",
|
||||||
...operationList.map(({ method, operation, path }) => {
|
...operationList.map(({ method, operation, path }) => {
|
||||||
const resultType = operationResult(operation);
|
const resultType = operationResult(operation, schemas);
|
||||||
const bodyType = operationBodyType(operation);
|
const bodyType = operationBodyType(operation, schemas);
|
||||||
const requestMediaType = operationRequestMediaType(operation);
|
const requestMediaType = operationRequestMediaType(operation);
|
||||||
if (operationMediaType(operation) === "text/event-stream") {
|
if (operationMediaType(operation) === "text/event-stream") {
|
||||||
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`;
|
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const phaseIndex = process.argv.indexOf("--phase");
|
||||||
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||||
|
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp3-02-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const casesDirectory = resolve(runDirectory, "cases");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(casesDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{ acceptance: ["AC-30", "AC-51"], evidence: ["response.json", "db-diff.json", "screenshots/model-states.png"], id: "TDD-WP3-MDL-003-runtime-recommendation", requirements: ["ADMIN-03", "GEN-15"] },
|
||||||
|
{ acceptance: ["AC-51"], evidence: ["response.json", "db-diff.json", "external-calls.json", "screenshots/recovery.png"], id: "TDD-WP3-BAL-001-confirmed-recovery", requirements: ["ADMIN-09", "GEN-14", "GEN-15"] },
|
||||||
|
];
|
||||||
|
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
||||||
|
|
||||||
|
const commands = phase === "red"
|
||||||
|
? [["unit-red", ["exec", "vitest", "run", "tests/unit/wp3-02-runtime-recommendation.test.ts"]], ["integration-red", ["exec", "vitest", "run", "tests/integration/wp3-02-runtime-recovery.test.ts"]]]
|
||||||
|
: [
|
||||||
|
["integration", ["test:integration"]],
|
||||||
|
["worker", ["test:worker"]],
|
||||||
|
["e2e", ["test:e2e"]],
|
||||||
|
["unit", ["test:unit"]],
|
||||||
|
["api", ["test:api"]],
|
||||||
|
["tdd-trace", ["validate:tdd-trace"]],
|
||||||
|
];
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_RUNTIME: casesDirectory,
|
||||||
|
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve("test-results", runId, "e2e"),
|
||||||
|
};
|
||||||
|
const commandResults = [];
|
||||||
|
for (const [name, args] of commands) {
|
||||||
|
const command = `pnpm ${args.join(" ")}`;
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandState = phase === "red" ? commandResults.every((result) => result.exit_code !== 0) : commandResults.every((result) => result.exit_code === 0);
|
||||||
|
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
const summaries = [];
|
||||||
|
for (const item of cases) {
|
||||||
|
const directory = resolve(casesDirectory, item.id);
|
||||||
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
|
||||||
|
if (phase === "red") writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({
|
||||||
|
expected_failure: "TASK-WP3-02 had no runtime recommendation derivation and WP2-06 worker runtime used an incompatible schema",
|
||||||
|
observed_commands: ["pnpm vitest run tests/unit/wp3-02-runtime-recommendation.test.ts", "pnpm vitest run tests/integration/wp3-02-runtime-recovery.test.ts"],
|
||||||
|
observed_errors: ["recommended_model_id was null", "SqliteError: table model_runtime_availability has no column named gateway_account_ref"],
|
||||||
|
status: "red_confirmed",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed";
|
||||||
|
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
||||||
|
acceptance_criteria: item.acceptance, automation: ["automated"], commit, evidence_refs: evidenceRefs,
|
||||||
|
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements,
|
||||||
|
run_id: runId, status, task_id: "TASK-WP3-02", test_id: item.id, work_package: "WP-3",
|
||||||
|
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
|
||||||
|
}
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
||||||
|
if (status !== targetStatus) process.exit(1);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-08-02T15:00:00.000Z");
|
||||||
|
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP3-02 model runtime API", () => {
|
||||||
|
it("returns recommended separately from configured default as runtime changes", async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-02-api-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x91), clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x92), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x93),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
||||||
|
const candidates = structuredClone(models.read().models);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
candidate.contract_validation_status = "verified";
|
||||||
|
candidate.contract_evidence_ref = `fixture-${candidate.model_id}`;
|
||||||
|
}
|
||||||
|
models.replace({ actorId: "wp3-02-api", expectedConfigSetVersion: 1, idempotencyKey: "wp3-02-api-verified-00000000000000000001", models: candidates });
|
||||||
|
registration.database.prepare("UPDATE model_runtime_availability SET available_for_new_jobs = 1, reason = 'available', checked_at = ?").run(now);
|
||||||
|
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
const available = await app.inject({ headers, method: "GET", url: "/api/v1/models" });
|
||||||
|
expect(available.statusCode).toBe(200);
|
||||||
|
expect(available.json()).toMatchObject({ configured_default_model_id: "gemini-3.1-flash-image-preview", recommended_model_id: "gemini-3.1-flash-image-preview" });
|
||||||
|
registration.database.prepare("UPDATE model_runtime_availability SET available_for_new_jobs = 0, reason = 'gateway_balance_insufficient', runtime_availability_version = runtime_availability_version + 1 WHERE model_id = ?")
|
||||||
|
.run("gemini-3.1-flash-image-preview");
|
||||||
|
const blocked = await app.inject({ headers, method: "GET", url: "/api/v1/models" });
|
||||||
|
expect(blocked.json()).toMatchObject({ configured_default_model_id: "gemini-3.1-flash-image-preview", recommended_model_id: "gemini-3-pro-image-preview" });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,18 @@ function configuration(version = 1) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeConfiguration() {
|
||||||
|
const result = configuration();
|
||||||
|
result.recommended_model_id = "gemini-3-pro-image-preview";
|
||||||
|
result.models[0].contract_validation_status = "verified";
|
||||||
|
result.models[1].contract_validation_status = "verified";
|
||||||
|
result.models[2].contract_validation_status = "verified";
|
||||||
|
result.models[0].runtime_availability = { available_for_new_jobs: false, checked_at: "2026-08-02T15:00:00.000Z", reason: "gateway_balance_insufficient" };
|
||||||
|
result.models[1].runtime_availability = { available_for_new_jobs: true, checked_at: "2026-08-02T15:00:00.000Z", reason: "available" };
|
||||||
|
result.models[2].runtime_availability = { available_for_new_jobs: false, checked_at: "2026-08-02T15:00:00.000Z", reason: "worker_degraded" };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function routeBase(page: Page) {
|
async function routeBase(page: Page) {
|
||||||
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
||||||
}
|
}
|
||||||
@@ -109,3 +121,33 @@ test("TDD-WP3-MDL-002-cas-conflict blocks saving until the complete set is refre
|
|||||||
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
||||||
await expect(page.getByRole("button", { name: "刷新最新配置" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "刷新最新配置" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("TDD-WP3-MDL-003 keeps configured default, runtime and recommendation independent", async ({ page }) => {
|
||||||
|
await routeBase(page);
|
||||||
|
let recovered = false;
|
||||||
|
await page.route("**/api/v1/models", (route) => route.fulfill({ body: JSON.stringify(recovered ? {
|
||||||
|
...runtimeConfiguration(),
|
||||||
|
recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
models: runtimeConfiguration().models.map((model) => ({ ...model, runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-02T15:01:00.000Z", reason: "available" } })),
|
||||||
|
} : runtimeConfiguration()), contentType: "application/json", status: 200 }));
|
||||||
|
await page.goto(`${webUrl}/admin/models`);
|
||||||
|
await expect(page.getByText("配置默认").locator("..").getByText("gemini-3.1-flash-image-preview", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("当前推荐").locator("..").getByText("gemini-3-pro-image-preview", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("不可用 · gateway_balance_insufficient")).toBeVisible();
|
||||||
|
await expect(page.getByText("可用于新任务")).toBeVisible();
|
||||||
|
await expect(page.locator("tbody tr").nth(1).getByText("是", { exact: true })).toBeVisible();
|
||||||
|
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_RUNTIME;
|
||||||
|
if (evidenceRoot) {
|
||||||
|
const directory = resolve(evidenceRoot, "TDD-WP3-MDL-003-runtime-recommendation", "screenshots");
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
await page.screenshot({ fullPage: true, path: resolve(directory, "model-states.png") });
|
||||||
|
}
|
||||||
|
recovered = true;
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator(".admin-models-summary").getByText("gemini-3.1-flash-image-preview", { exact: true }).last()).toBeVisible();
|
||||||
|
if (evidenceRoot) {
|
||||||
|
const directory = resolve(evidenceRoot, "TDD-WP3-BAL-001-confirmed-recovery", "screenshots");
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
await page.screenshot({ fullPage: true, path: resolve(directory, "recovery.png") });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
import { GatewayBalanceRuntime } from "../../apps/worker/src/gateway-balance-runtime.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const runtimes: GatewayBalanceRuntime[] = [];
|
||||||
|
const now = Date.parse("2026-08-02T15:00:00.000Z");
|
||||||
|
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"] as const;
|
||||||
|
|
||||||
|
function evidence(caseId: string, file: string, value: unknown) {
|
||||||
|
const root = process.env.DADA_EVIDENCE_DIR_RUNTIME;
|
||||||
|
if (!root) return;
|
||||||
|
const directory = resolve(root, caseId);
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-02-recovery-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x81), clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x82), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x83),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
||||||
|
const candidates = structuredClone(models.read().models);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
candidate.contract_validation_status = "verified";
|
||||||
|
candidate.contract_evidence_ref = `fixture-${candidate.model_id}`;
|
||||||
|
}
|
||||||
|
models.replace({ actorId: "wp3-02-recovery", expectedConfigSetVersion: 1, idempotencyKey: "wp3-02-recovery-verified-00000000000000000001", models: candidates });
|
||||||
|
registration.database.prepare("UPDATE model_runtime_availability SET available_for_new_jobs = 1, reason = 'available', checked_at = ?").run(now);
|
||||||
|
const runtime = new GatewayBalanceRuntime({ clock: () => now, database: registration.database });
|
||||||
|
runtimes.push(runtime);
|
||||||
|
runtime.seedModels(ids.map((modelId) => ({ gatewayAccountRef: "gateway-account-primary", modelId })));
|
||||||
|
return { models, registration, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const runtime of runtimes.splice(0).reverse()) runtime.close();
|
||||||
|
for (const service of services.splice(0).reverse()) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP3-BAL-001-confirmed-recovery", () => {
|
||||||
|
it.each(["model", "account", "unknown"] as const)("requires health check and explicit admin confirmation for %s impact", (impactScope) => {
|
||||||
|
const { models, registration, runtime } = harness();
|
||||||
|
const before = registration.database.prepare("SELECT model_id, config_version, contract_fingerprint FROM model_config_versions ORDER BY model_id, config_version").all();
|
||||||
|
const event = runtime.recordInsufficient({
|
||||||
|
eventId: randomUUID(), gatewayAccountRef: "gateway-account-primary", impactScope,
|
||||||
|
modelId: ids[0], sourceCategory: "adapter_balance_signal",
|
||||||
|
});
|
||||||
|
expect(event.recoveryStatus).toBe("awaiting_confirmation");
|
||||||
|
expect(() => runtime.confirmRecovery({ actorId: randomUUID(), gatewayAccountRef: "gateway-account-primary" }))
|
||||||
|
.toThrow("gateway_balance_recovery_unconfirmed");
|
||||||
|
|
||||||
|
const check = runtime.runRecoveryCheck("gateway-account-primary");
|
||||||
|
expect(check).toMatchObject({ gateway_account_ref: "gateway-account-primary", status: "passed" });
|
||||||
|
const recovered = runtime.confirmRecovery({
|
||||||
|
actorId: randomUUID(), gatewayAccountRef: "gateway-account-primary", recoveryCheckId: check.check_id,
|
||||||
|
});
|
||||||
|
expect(recovered).toMatchObject({ recoveryStatus: "confirmed", balanceStatus: "available" });
|
||||||
|
expect(runtime.listRuntime().every((model) => model.availableForNewJobs && model.runtimeReason === "available")).toBe(true);
|
||||||
|
expect(models.read()).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[0] });
|
||||||
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'gateway_balance_recovery' AND result = 'succeeded'").get()).toEqual({ count: 1 });
|
||||||
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM service_recovery_checks WHERE check_id = ?").get(check.check_id)).toEqual({ count: 1 });
|
||||||
|
const after = registration.database.prepare("SELECT model_id, config_version, contract_fingerprint FROM model_config_versions ORDER BY model_id, config_version").all();
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
evidence("TDD-WP3-BAL-001-confirmed-recovery", "response.json", { impact_scope: impactScope, event, check, recovered });
|
||||||
|
evidence("TDD-WP3-BAL-001-confirmed-recovery", "db-diff.json", { config_before: before, config_after: after, config_set_version: models.read().config_set_version });
|
||||||
|
evidence("TDD-WP3-BAL-001-confirmed-recovery", "external-calls.json", { automatic_recharge_calls: 0, recovery_calls: 0, user_credit_adjustments: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a contract blocker after balance recovery", () => {
|
||||||
|
const { models, runtime } = harness();
|
||||||
|
runtime.recordInsufficient({
|
||||||
|
eventId: randomUUID(), gatewayAccountRef: "gateway-account-primary", impactScope: "model",
|
||||||
|
modelId: ids[0], sourceCategory: "adapter_balance_signal",
|
||||||
|
});
|
||||||
|
const changed = structuredClone(models.read().models);
|
||||||
|
changed[0].route_profile.endpoint = "https://mock.invalid/v2/images";
|
||||||
|
models.replace({ actorId: "wp3-02-contract", expectedConfigSetVersion: 2, idempotencyKey: "wp3-02-contract-change-00000000000000000001", models: changed });
|
||||||
|
const check = runtime.runRecoveryCheck("gateway-account-primary");
|
||||||
|
runtime.confirmRecovery({ actorId: randomUUID(), gatewayAccountRef: "gateway-account-primary", recoveryCheckId: check.check_id });
|
||||||
|
expect(runtime.listRuntime().find((model) => model.modelId === ids[0])).toMatchObject({ availableForNewJobs: false, runtimeReason: "contract_unverified" });
|
||||||
|
expect(models.read()).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-08-02T15:00:00.000Z");
|
||||||
|
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
||||||
|
|
||||||
|
function evidence(file: string, value: unknown) {
|
||||||
|
const root = process.env.DADA_EVIDENCE_DIR_RUNTIME;
|
||||||
|
if (!root) return;
|
||||||
|
const directory = resolve(root, "TDD-WP3-MDL-003-runtime-recommendation");
|
||||||
|
mkdirSync(dirname(resolve(directory, file)), { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-02-runtime-unit-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x71),
|
||||||
|
clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x72),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x73),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
||||||
|
const candidates = structuredClone(models.read().models);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
candidate.contract_validation_status = "verified";
|
||||||
|
candidate.contract_evidence_ref = `fixture-${candidate.model_id}`;
|
||||||
|
}
|
||||||
|
models.replace({ actorId: "wp3-02-unit", expectedConfigSetVersion: 1, idempotencyKey: "wp3-02-unit-verified-00000000000000000001", models: candidates });
|
||||||
|
registration.database.prepare("UPDATE model_runtime_availability SET available_for_new_jobs = 1, reason = 'available', checked_at = ?").run(now);
|
||||||
|
return { models, registration };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP3-MDL-003 runtime recommendation", () => {
|
||||||
|
it("keeps configured default independent while deriving recommendation from runtime", () => {
|
||||||
|
const { models, registration } = harness();
|
||||||
|
const before = registration.database.prepare("SELECT model_id, config_version, contract_fingerprint FROM model_config_versions ORDER BY model_id, config_version").all();
|
||||||
|
const states: unknown[] = [];
|
||||||
|
const mark = (modelId: string, available: boolean, reason: string) => {
|
||||||
|
registration.database.prepare("UPDATE model_runtime_availability SET available_for_new_jobs = ?, reason = ?, runtime_availability_version = runtime_availability_version + 1, checked_at = ? WHERE model_id = ?")
|
||||||
|
.run(available ? 1 : 0, reason, now, modelId);
|
||||||
|
const current = models.read();
|
||||||
|
states.push(current);
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(models.read()).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[0] });
|
||||||
|
expect(mark(ids[0], false, "gateway_balance_insufficient")).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[1] });
|
||||||
|
mark(ids[1], false, "worker_degraded");
|
||||||
|
mark(ids[2], false, "gateway_paused");
|
||||||
|
expect(models.read().recommended_model_id).toBeNull();
|
||||||
|
expect(mark(ids[1], true, "available")).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[1] });
|
||||||
|
expect(mark(ids[0], true, "available")).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[0] });
|
||||||
|
expect(mark(ids[2], true, "available")).toMatchObject({ configured_default_model_id: ids[0], recommended_model_id: ids[0] });
|
||||||
|
|
||||||
|
const after = registration.database.prepare("SELECT model_id, config_version, contract_fingerprint FROM model_config_versions ORDER BY model_id, config_version").all();
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
expect(models.read().models.find((model) => model.model_id === ids[0])).toMatchObject({ enabled: true, is_default: true, recommendation_priority: 1 });
|
||||||
|
evidence("response.json", { states });
|
||||||
|
evidence("db-diff.json", { config_before: before, config_after: after, config_set_version: models.read().config_set_version });
|
||||||
|
evidence("screenshots/model-states.png", { generated_by: "tests/e2e/admin-models.spec.ts", state_count: states.length });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
import { GatewayBalanceRuntime } from "../../apps/worker/src/gateway-balance-runtime.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const closeables: Array<{ close(): void }> = [];
|
||||||
|
const now = Date.parse("2026-08-02T15:00:00.000Z");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const closeable of closeables.splice(0).reverse()) closeable.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP3-02 worker recovery boundary", () => {
|
||||||
|
it("does not auto-restore a balance block before a confirmed recovery", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-02-worker-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0xa1), clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0xa2), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0xa3),
|
||||||
|
});
|
||||||
|
closeables.push(registration);
|
||||||
|
const runtime = new GatewayBalanceRuntime({ clock: () => now, database: registration.database });
|
||||||
|
closeables.push(runtime);
|
||||||
|
runtime.seedModels([{ gatewayAccountRef: "gateway-account-primary", modelId: "gemini-3.1-flash-image-preview" }]);
|
||||||
|
runtime.recordInsufficient({ eventId: randomUUID(), gatewayAccountRef: "gateway-account-primary", impactScope: "model", modelId: "gemini-3.1-flash-image-preview", sourceCategory: "adapter_balance_signal" });
|
||||||
|
expect(() => runtime.restoreWithoutConfirmedRecovery("gateway-account-primary", randomUUID())).toThrow("gateway_balance_recovery_unconfirmed");
|
||||||
|
expect(runtime.readState("gateway-account-primary")).toMatchObject({ recoveryStatus: "awaiting_confirmation" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user