From 827ae912e0b32498d37216ab50bb8ad223a8bd78 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 03:00:12 +0800 Subject: [PATCH] feat: complete TASK-WP3-02 runtime recommendation --- apps/api/src/model-configuration.ts | 7 +- apps/web/src/generated/api/sdk.gen.ts | 145 +---------- apps/web/src/generated/api/types.gen.ts | 12 +- apps/worker/src/gateway-balance-runtime.ts | 239 ++++++++++++++---- package.json | 4 +- scripts/lib/generate-client.mjs | 36 +-- scripts/run-wp3-02-validation.mjs | 76 ++++++ tests/api/wp3-02-model-runtime.test.ts | 50 ++++ tests/e2e/admin-models.spec.ts | 42 +++ .../wp3-02-runtime-recovery.test.ts | 99 ++++++++ .../wp3-02-runtime-recommendation.test.ts | 82 ++++++ .../wp3-02-gateway-balance-recovery.test.ts | 38 +++ 12 files changed, 627 insertions(+), 203 deletions(-) create mode 100644 scripts/run-wp3-02-validation.mjs create mode 100644 tests/api/wp3-02-model-runtime.test.ts create mode 100644 tests/integration/wp3-02-runtime-recovery.test.ts create mode 100644 tests/unit/wp3-02-runtime-recommendation.test.ts create mode 100644 tests/worker/wp3-02-gateway-balance-recovery.test.ts diff --git a/apps/api/src/model-configuration.ts b/apps/api/src/model-configuration.ts index 77b7869..f8ff23e 100644 --- a/apps/api/src/model-configuration.ts +++ b/apps/api/src/model-configuration.ts @@ -55,7 +55,7 @@ export interface ModelConfigView extends ModelConfigCandidate { export interface ModelConfigurationView { config_set_version: number; configured_default_model_id: ModelId; - recommended_model_id: null; + recommended_model_id: ModelId | null; models: ModelConfigView[]; } @@ -258,10 +258,13 @@ export class ModelConfigurationService { reason: row.reason, }, } satisfies ModelConfigView)); + const recommended = models.find((model) => model.enabled + && model.contract_validation_status === "verified" + && model.runtime_availability.available_for_new_jobs); return { config_set_version: current.config_set_version, 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, }; } diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index e39298f..37c618d 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // 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; } @@ -13,45 +13,13 @@ export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, opti return response.json() as Promise; } -export async function checkBrowserSupport(body: { - "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; -}>; -}> { +export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); headers.set("Content-Type", "application/json"); 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}`); - return response.json() as Promise<{ - "app_version": string; - "browser": { - "brand": "Google Chrome" | "Microsoft Edge"; - "major": number; -}; - "status": "supported"; - "supported_browsers": Array<{ - "brand": "Google Chrome" | "Microsoft Edge"; - "major": number; -}>; -}>; + return response.json() as Promise; } export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise { @@ -133,43 +101,11 @@ export async function getAdminUserCredits(options: ClientOptions = {}): Promise< return response.json() as Promise; } -export async function getBootstrap(options: ClientOptions = {}): Promise<{ - "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 getBootstrap(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise<{ - "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"; -}>; -}>; + return response.json() as Promise; } export async function getCurrentGeneration(options: ClientOptions = {}): Promise { @@ -190,64 +126,18 @@ export async function getGeneration(options: ClientOptions = {}): Promise; } -export async function getModel(options: ClientOptions = {}): Promise<{ - "config_version": number; - "contract_evidence_ref": string | null; - "contract_validation_status": ModelContractValidationStatus; - "credit_cost": number; - "display_name": string; - "enabled": boolean; - "error_mapping_profile": Record; - "gateway_account_ref": string; - "is_default": boolean; - "model_id": ModelId; - "prompt_max_length": number; - "recommendation_priority": number; - "reference_limits": ModelReferenceLimits; - "route_profile": Record; - "runtime_availability": ModelRuntimeAvailability; - "safety_source": string; - "supported_ratios": Array; -}> { +export async function getModel(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; 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}`); - return response.json() as Promise<{ - "config_version": number; - "contract_evidence_ref": string | null; - "contract_validation_status": ModelContractValidationStatus; - "credit_cost": number; - "display_name": string; - "enabled": boolean; - "error_mapping_profile": Record; - "gateway_account_ref": string; - "is_default": boolean; - "model_id": ModelId; - "prompt_max_length": number; - "recommendation_priority": number; - "reference_limits": ModelReferenceLimits; - "route_profile": Record; - "runtime_availability": ModelRuntimeAvailability; - "safety_source": string; - "supported_ratios": Array; -}>; + return response.json() as Promise; } -export async function getModels(options: ClientOptions = {}): Promise<{ - "config_set_version": number; - "configured_default_model_id": ModelId; - "models": Array; - "recommended_model_id": ModelId | null; -}> { +export async function getModels(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise<{ - "config_set_version": number; - "configured_default_model_id": ModelId; - "models": Array; - "recommended_model_id": ModelId | null; -}>; + return response.json() as Promise; } export async function getMyCreditLedger(options: ClientOptions = {}): Promise { @@ -308,26 +198,13 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO return response.json() as Promise; } -export async function replaceModelConfiguration(body: { - "expected_config_set_version": number; - "models": Array; -}, options: ClientOptions = {}): Promise<{ - "config_set_version": number; - "configured_default_model_id": ModelId; - "models": Array; - "recommended_model_id": ModelId | null; -}> { +export async function replaceModelConfiguration(body: ModelConfigUpdateRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); headers.set("Content-Type", "application/json"); 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}`); - return response.json() as Promise<{ - "config_set_version": number; - "configured_default_model_id": ModelId; - "models": Array; - "recommended_model_id": ModelId | null; -}>; + return response.json() as Promise; } export async function restoreProject(options: ClientOptions = {}): Promise { diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index a6a9150..3516d73 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -164,7 +164,7 @@ export type CanvasElement = { "longitude": number; }; "created_at": string; - "dynamic_fields"?: Record; + "dynamic_fields"?: Record; "element_id": string; "font_override"?: string; "font_size"?: number; @@ -181,7 +181,7 @@ export type CanvasElement = { "y": number; }; "style_id"?: string; - "style_parameters"?: Record; + "style_parameters"?: Record; "template_or_asset_id": string; "type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker"; "z_index": number; @@ -457,14 +457,14 @@ export type ModelConfig = { "credit_cost": number; "display_name": string; "enabled": boolean; - "error_mapping_profile": Record; + "error_mapping_profile": Record; "gateway_account_ref": string; "is_default": boolean; "model_id": ModelId; "prompt_max_length": number; "recommendation_priority": number; "reference_limits": ModelReferenceLimits; - "route_profile": Record; + "route_profile": Record; "runtime_availability": ModelRuntimeAvailability; "safety_source": string; "supported_ratios": Array; @@ -474,14 +474,14 @@ export type ModelConfigCandidate = { "credit_cost": number; "display_name": string; "enabled": boolean; - "error_mapping_profile": Record; + "error_mapping_profile": Record; "gateway_account_ref": string; "is_default": boolean; "model_id": ModelId; "prompt_max_length": number; "recommendation_priority": number; "reference_limits": ModelReferenceLimits; - "route_profile": Record; + "route_profile": Record; "safety_source": string; "supported_ratios": Array; }; diff --git a/apps/worker/src/gateway-balance-runtime.ts b/apps/worker/src/gateway-balance-runtime.ts index b6ddf70..02085c5 100644 --- a/apps/worker/src/gateway-balance-runtime.ts +++ b/apps/worker/src/gateway-balance-runtime.ts @@ -6,17 +6,28 @@ import type BetterSqlite3 from "better-sqlite3"; import { configureWorkerDatabase } from "./sqlite-connection.js"; const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000; +const recoveryCheckLifetimeMilliseconds = 5 * 60 * 1_000; const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/; 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 { available_for_new_jobs: number; + checked_at: number; + model_id: string; + reason: RuntimeReason; + runtime_availability_version: number; +} + +interface MappingRow { balance_blocked: number; gateway_account_ref: string; model_id: string; - runtime_reason: string; - runtime_version: number; - updated_at: number; + previous_runtime_reason: RuntimeReason | null; } interface BalanceRow { @@ -43,6 +54,14 @@ export interface GatewayBalanceView { sourceCategory: string; } +export interface GatewayRecoveryCheck { + check_id: string; + expires_at: string; + gateway_account_ref: string; + status: "passed"; + checked_at: string; +} + export class GatewayBalanceRuntime { readonly database: BetterSqlite3.Database; 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"); this.database.prepare(` INSERT INTO model_runtime_availability ( - model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at - ) VALUES (?, ?, 0, 1, 'available', 1, ?) - ON CONFLICT(model_id) DO UPDATE SET gateway_account_ref = excluded.gateway_account_ref - `).run(model.modelId, model.gatewayAccountRef, now); + model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version + ) VALUES (?, 1, 'available', ?, 1) + ON CONFLICT(model_id) DO NOTHING + `).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 = ?") .get(input.eventId) as { response_json: string } | undefined; if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView; - this.database.prepare(` - INSERT INTO model_runtime_availability ( - model_id, gateway_account_ref, balance_blocked, available_for_new_jobs, runtime_reason, runtime_version, updated_at - ) 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") + + this.ensureModelMapping(input.gatewayAccountRef, input.modelId); + const accountModels = this.database.prepare("SELECT model_id FROM gateway_balance_affected_models WHERE gateway_account_ref = ? ORDER BY model_id") .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 = ?") .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 now = this.clock(); for (const modelId of unavailable) { - this.database.prepare(` - UPDATE model_runtime_availability - SET balance_blocked = 1, available_for_new_jobs = 0, runtime_reason = 'gateway_balance_insufficient', - runtime_version = runtime_version + 1, updated_at = ? - WHERE model_id = ? AND gateway_account_ref = ? AND balance_blocked = 0 - `).run(now, modelId, input.gatewayAccountRef); + const mapping = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND model_id = ?") + .get(input.gatewayAccountRef, modelId) as MappingRow | undefined; + const runtime = this.database.prepare("SELECT * FROM model_runtime_availability WHERE model_id = ?").get(modelId) as RuntimeRow | undefined; + if (!mapping || !runtime) continue; + if (mapping.balance_blocked === 0) { + 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(` INSERT INTO gateway_balance_states ( @@ -141,24 +169,117 @@ export class GatewayBalanceRuntime { } 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; return rows.map((row) => ({ availableForNewJobs: row.available_for_new_jobs === 1, balanceBlocked: row.balance_blocked === 1, gatewayAccountRef: row.gateway_account_ref, modelId: row.model_id, - runtimeReason: row.runtime_reason, - runtimeVersion: row.runtime_version, - updatedAt: new Date(row.updated_at).toISOString(), + runtimeReason: row.reason, + runtimeVersion: row.runtime_availability_version, + 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) { const state = this.readState(gatewayAccountRef); if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed"); 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 { return { affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[], @@ -187,18 +308,39 @@ export class GatewayBalanceRuntime { } 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(` - CREATE TABLE IF NOT EXISTS model_runtime_availability ( - model_id TEXT PRIMARY KEY, + CREATE TABLE IF NOT EXISTS gateway_balance_affected_models ( gateway_account_ref TEXT NOT NULL, + model_id TEXT NOT NULL, 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)), - runtime_reason TEXT NOT NULL CHECK (runtime_reason IN ( - '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 + 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')), + PRIMARY KEY (gateway_account_ref, model_id) ); CREATE TABLE IF NOT EXISTS gateway_balance_states ( gateway_account_ref TEXT PRIMARY KEY, @@ -217,25 +359,32 @@ export class GatewayBalanceRuntime { response_json TEXT NOT NULL CHECK (json_valid(response_json)), 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 ( log_id TEXT PRIMARY KEY, 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), - operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1), - target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1), - target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, 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), - after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1), + before_summary TEXT, + after_summary TEXT, 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 - 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 - BEFORE DELETE ON admin_operation_logs - WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now() - BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); } } diff --git a/package.json b/package.json index a7361db..ecf81e5 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,9 @@ "test:wp2-07": "node scripts/run-wp2-07-validation.mjs", "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: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": { "@playwright/test": "1.62.0", diff --git a/scripts/lib/generate-client.mjs b/scripts/lib/generate-client.mjs index 274c7e8..59f0da4 100644 --- a/scripts/lib/generate-client.mjs +++ b/scripts/lib/generate-client.mjs @@ -5,17 +5,19 @@ function identifier(value) { return value.replaceAll(/[^A-Za-z0-9_$]/g, "_"); } -function schemaType(schema) { +function schemaType(schema, namedSchemas = []) { if (!schema || typeof schema !== "object") return "unknown"; 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 (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | "); - if (schema.anyOf) return schema.anyOf.map(schemaType).join(" | "); - if (schema.oneOf) return schema.oneOf.map(schemaType).join(" | "); + if (schema.anyOf) return schema.anyOf.map((item) => schemaType(item, namedSchemas)).join(" | "); + if (schema.oneOf) return schema.oneOf.map((item) => schemaType(item, namedSchemas)).join(" | "); 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 === "integer" || schema.type === "number") return "number"; if (schema.type === "null") return "null"; @@ -23,27 +25,31 @@ function schemaType(schema) { if (schema.type === "object" || schema.properties) { const required = new Set(schema.required ?? []); 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`; + } + if (!fields.length && schema.additionalProperties === true) return "Record"; return fields.length ? `{\n${fields.join("\n")}\n}` : "Record"; } return "unknown"; } -function operationResult(operation) { +function operationResult(operation, namedSchemas) { const response = operation.responses?.["200"]; if (!response) return "unknown"; if (response.content) { 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 jsonSchema = content?.["application/json"]?.schema; - if (jsonSchema) return schemaType(jsonSchema); + if (jsonSchema) return schemaType(jsonSchema, namedSchemas); if (content?.["multipart/form-data"]?.schema) return "FormData"; return undefined; } @@ -90,16 +96,16 @@ export function generateClient(input, output) { const operationList = operations(document); const builtInTypes = new Set(["Blob", "FormData", "boolean", "number", "string", "unknown"]); const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [ - operationResult(operation), - operationBodyType(operation), + operationResult(operation, schemas), + operationBodyType(operation, schemas), ]).filter((type) => type && !builtInTypes.has(type) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; const sdk = [ "// Generated from openapi/openapi.json. Do not edit by hand.", importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "", "export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }", ...operationList.map(({ method, operation, path }) => { - const resultType = operationResult(operation); - const bodyType = operationBodyType(operation); + const resultType = operationResult(operation, schemas); + const bodyType = operationBodyType(operation, schemas); const requestMediaType = operationRequestMediaType(operation); if (operationMediaType(operation) === "text/event-stream") { return `export function ${identifier(operation.operationId)}(options: Pick = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`; diff --git a/scripts/run-wp3-02-validation.mjs b/scripts/run-wp3-02-validation.mjs new file mode 100644 index 0000000..06065c3 --- /dev/null +++ b/scripts/run-wp3-02-validation.mjs @@ -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); diff --git a/tests/api/wp3-02-model-runtime.test.ts b/tests/api/wp3-02-model-runtime.test.ts new file mode 100644 index 0000000..b9e6236 --- /dev/null +++ b/tests/api/wp3-02-model-runtime.test.ts @@ -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(); + }); +}); diff --git a/tests/e2e/admin-models.spec.ts b/tests/e2e/admin-models.spec.ts index 9c108cd..fe47159 100644 --- a/tests/e2e/admin-models.spec.ts +++ b/tests/e2e/admin-models.spec.ts @@ -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) { 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: "刷新最新配置" })).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") }); + } +}); diff --git a/tests/integration/wp3-02-runtime-recovery.test.ts b/tests/integration/wp3-02-runtime-recovery.test.ts new file mode 100644 index 0000000..eae09ff --- /dev/null +++ b/tests/integration/wp3-02-runtime-recovery.test.ts @@ -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] }); + }); +}); diff --git a/tests/unit/wp3-02-runtime-recommendation.test.ts b/tests/unit/wp3-02-runtime-recommendation.test.ts new file mode 100644 index 0000000..223c8f7 --- /dev/null +++ b/tests/unit/wp3-02-runtime-recommendation.test.ts @@ -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 }); + }); +}); diff --git a/tests/worker/wp3-02-gateway-balance-recovery.test.ts b/tests/worker/wp3-02-gateway-balance-recovery.test.ts new file mode 100644 index 0000000..8d4f443 --- /dev/null +++ b/tests/worker/wp3-02-gateway-balance-recovery.test.ts @@ -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" }); + }); +});