feat: complete TASK-WP3-02 runtime recommendation

This commit is contained in:
suyx
2026-08-03 03:00:12 +08:00
parent d79ac74fdd
commit 827ae912e0
12 changed files with 627 additions and 203 deletions
+50
View File
@@ -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();
});
});
+42
View File
@@ -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") });
}
});
@@ -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" });
});
});