Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7cac5dabf | ||
|
|
a7f62adad4 | ||
|
|
b1f143c238 | ||
|
|
99fd3b1802 | ||
|
|
fd0003a804 | ||
|
|
bfdfe44f87 | ||
|
|
cbb7f658a3 | ||
|
|
43d946bb5c | ||
|
|
79b01ebc81 | ||
|
|
90f812fae5 | ||
|
|
1155a81c3b | ||
|
|
3dca4ad77c | ||
|
|
99fe07a761 | ||
|
|
443e8b94f0 | ||
|
|
693fa117b7 | ||
|
|
08f3cccae4 | ||
|
|
a22b1f19e9 | ||
|
|
194b59d4a5 | ||
|
|
0f03b12f64 | ||
|
|
ad86b4ddcc | ||
|
|
08e9c39e49 | ||
|
|
ffd1643848 | ||
|
|
95ab0cb93b | ||
|
|
f7bed92e61 | ||
|
|
d03b491d2f | ||
|
|
8c349fb56c | ||
|
|
55646ba1b4 | ||
|
|
8abf1397a6 | ||
|
|
e0e101ef28 | ||
|
|
33f87f8db3 | ||
|
|
a7772a7e92 | ||
|
|
2bfb5f2953 | ||
|
|
4ec8327f5e | ||
|
|
d474768a2b |
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"appVersion": "0.0.0",
|
||||||
|
"browsers": [
|
||||||
|
{
|
||||||
|
"brand": "Google Chrome",
|
||||||
|
"fullVersion": "150.0.7871.187"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brand": "Microsoft Edge",
|
||||||
|
"fullVersion": "151.0.4129.59"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildCommit": "08f3cccae4a1e75e2f2292eef14611313523916d",
|
||||||
|
"deferredExternalTasks": [
|
||||||
|
"TASK-WP7-03",
|
||||||
|
"TASK-WP7-04"
|
||||||
|
],
|
||||||
|
"finalRelease": true,
|
||||||
|
"fixedPort": 43121,
|
||||||
|
"frozenFromCommit": "08e9c39e49d68f8642d5acfe22b0fdb40a3a08fa",
|
||||||
|
"recordedAt": "2026-08-04T15:20:54.271Z",
|
||||||
|
"releaseStatus": "first_version_internal",
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"windows": {
|
||||||
|
"arch": "x64",
|
||||||
|
"build": "26200.8875",
|
||||||
|
"displayVersion": "25H2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
|
import { request as httpsRequest } from "node:https";
|
||||||
|
|
||||||
|
const amapHostname = "restapi.amap.com" as const;
|
||||||
|
const amapMaxResponseBytes = 65_536;
|
||||||
|
const amapTimeoutMs = 15_000;
|
||||||
|
|
||||||
|
export interface AmapHttpRequest {
|
||||||
|
allowRedirects: false;
|
||||||
|
hostname: typeof amapHostname;
|
||||||
|
maxResponseBytes: number;
|
||||||
|
method: "GET";
|
||||||
|
path: string;
|
||||||
|
protocol: "https:";
|
||||||
|
rejectUnauthorized: true;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmapRequester = (request: AmapHttpRequest) => Promise<unknown>;
|
||||||
|
|
||||||
|
export class AmapAdapterError extends Error {
|
||||||
|
constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AmapAdapter {
|
export interface AmapAdapter {
|
||||||
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
|
dispose?(): void;
|
||||||
|
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MockAmapAdapter implements AmapAdapter {
|
export class MockAmapAdapter implements AmapAdapter {
|
||||||
@@ -13,3 +39,126 @@ export class MockAmapAdapter implements AmapAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestAmapJson(input: AmapHttpRequest) {
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) {
|
||||||
|
reject(new AmapAdapterError("amap_invalid_request"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
const request = httpsRequest({
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
hostname: input.hostname,
|
||||||
|
method: input.method,
|
||||||
|
path: input.path,
|
||||||
|
port: 443,
|
||||||
|
protocol: input.protocol,
|
||||||
|
rejectUnauthorized: input.rejectUnauthorized,
|
||||||
|
servername: input.hostname,
|
||||||
|
}, (response) => {
|
||||||
|
const statusCode = response.statusCode ?? 0;
|
||||||
|
if (statusCode >= 300 && statusCode < 400) {
|
||||||
|
response.resume();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_redirect_rejected")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
response.resume();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_provider_unavailable")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const declaredLength = Number(response.headers["content-length"] ?? 0);
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let receivedBytes = 0;
|
||||||
|
response.on("data", (chunk: Buffer | string) => {
|
||||||
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
receivedBytes += bytes.length;
|
||||||
|
if (receivedBytes > input.maxResponseBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(bytes);
|
||||||
|
});
|
||||||
|
response.on("end", () => {
|
||||||
|
finish(() => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
} catch {
|
||||||
|
reject(new AmapAdapterError("amap_invalid_response"));
|
||||||
|
} finally {
|
||||||
|
for (const chunk of chunks) chunk.fill(0);
|
||||||
|
chunks.length = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout")));
|
||||||
|
request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable"))));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealAmapAdapter implements AmapAdapter {
|
||||||
|
private readonly credential: Buffer;
|
||||||
|
private readonly requester: AmapRequester;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(value: string, options: { request?: AmapRequester } = {}) {
|
||||||
|
if (!value.trim()) throw new AmapAdapterError("amap_invalid_request");
|
||||||
|
this.credential = Buffer.from(value, "utf8");
|
||||||
|
this.requester = options.request ?? requestAmapJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||||
|
if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed");
|
||||||
|
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||||
|
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||||
|
throw new AmapAdapterError("amap_invalid_request");
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
extensions: "base",
|
||||||
|
key: this.credential.toString("utf8"),
|
||||||
|
location: `${coordinates.longitude},${coordinates.latitude}`,
|
||||||
|
});
|
||||||
|
const response = await this.requester({
|
||||||
|
allowRedirects: false,
|
||||||
|
hostname: amapHostname,
|
||||||
|
maxResponseBytes: amapMaxResponseBytes,
|
||||||
|
method: "GET",
|
||||||
|
path: `/v3/geocode/regeo?${query.toString()}`,
|
||||||
|
protocol: "https:",
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
timeoutMs: amapTimeoutMs,
|
||||||
|
});
|
||||||
|
if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) {
|
||||||
|
throw new AmapAdapterError("amap_provider_rejected");
|
||||||
|
}
|
||||||
|
const formattedValue = typeof response.regeocode.formatted_address === "string"
|
||||||
|
? response.regeocode.formatted_address.trim()
|
||||||
|
: "";
|
||||||
|
if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response");
|
||||||
|
return { formattedValue, serviceMode: "real" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.credential.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+42
-4
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import { createReadStream, readFileSync } from "node:fs";
|
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { extname, resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccountDeletionCompleteRequestSchema,
|
AccountDeletionCompleteRequestSchema,
|
||||||
@@ -218,6 +218,24 @@ import type { ManagedStorage } from "./managed-storage.js";
|
|||||||
import { PrivateContentError, PrivateContentService } from "./private-content.js";
|
import { PrivateContentError, PrivateContentService } from "./private-content.js";
|
||||||
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
|
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
|
||||||
|
|
||||||
|
const productAssetContentTypes: Readonly<Record<string, string>> = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function productAssetContentType(path: string) {
|
||||||
|
return productAssetContentTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
app_version: "0.0.0",
|
app_version: "0.0.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -238,6 +256,7 @@ export interface CreateAppOptions {
|
|||||||
assetReleases?: AssetReleaseReader;
|
assetReleases?: AssetReleaseReader;
|
||||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
browserGate?: boolean;
|
browserGate?: boolean;
|
||||||
|
productIndexHtml?: string;
|
||||||
browserSupportRelease?: BrowserSupportRelease;
|
browserSupportRelease?: BrowserSupportRelease;
|
||||||
browserSupportSecret?: Buffer;
|
browserSupportSecret?: Buffer;
|
||||||
credits?: CreditService;
|
credits?: CreditService;
|
||||||
@@ -272,6 +291,10 @@ const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps
|
|||||||
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
||||||
|
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
|
||||||
|
const packagedProductIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
|
||||||
|
? readFileSync(resolve(productWebRoot, "index.html"), "utf8")
|
||||||
|
: undefined;
|
||||||
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
||||||
const contentSecurityPolicy = [
|
const contentSecurityPolicy = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
@@ -710,6 +733,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
)
|
)
|
||||||
: undefined);
|
: undefined);
|
||||||
const browserGate = options.browserGate ?? true;
|
const browserGate = options.browserGate ?? true;
|
||||||
|
const productIndexHtml = options.productIndexHtml ?? packagedProductIndexHtml;
|
||||||
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
||||||
const browserSupportRelease = options.browserSupportRelease;
|
const browserSupportRelease = options.browserSupportRelease;
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -901,11 +925,25 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
||||||
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
|
app.get(route, { schema: { hide: true } }, async (request, reply) => {
|
||||||
reply.type("text/html; charset=utf-8");
|
reply.type("text/html; charset=utf-8");
|
||||||
return supportGateHtml;
|
if (!browserGate) return productIndexHtml ?? supportGateHtml;
|
||||||
|
const verified = verifyBrowserSupportCookie({
|
||||||
|
cookieHeader: headerValue(request.headers.cookie),
|
||||||
|
release: browserSupportRelease,
|
||||||
|
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
||||||
|
secret: browserSupportSecret,
|
||||||
|
});
|
||||||
|
return verified.supported && productIndexHtml ? productIndexHtml : supportGateHtml;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
app.get("/assets/*", { schema: { hide: true } }, async (request, reply) => {
|
||||||
|
const relativePath = decodeURIComponent(request.url.split("?", 1)[0]!.slice("/assets/".length));
|
||||||
|
const assetPath = resolve(productWebRoot, "assets", relativePath);
|
||||||
|
if (!assetPath.startsWith(resolve(productWebRoot, "assets")) || !existsSync(assetPath)) return reply.code(404).send();
|
||||||
|
reply.type(productAssetContentType(assetPath));
|
||||||
|
return reply.send(readFileSync(assetPath));
|
||||||
|
});
|
||||||
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
||||||
reply.type("text/css; charset=utf-8");
|
reply.type("text/css; charset=utf-8");
|
||||||
return supportGateCss;
|
return supportGateCss;
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const fixedDirectories = [
|
|||||||
"logs/supervisor",
|
"logs/supervisor",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export function ensureLocalDataRuntimeDirectories(dataRoot: string) {
|
||||||
|
for (const directory of fixedDirectories) {
|
||||||
|
mkdirSync(join(resolve(dataRoot), directory), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const DATA_TRANSFER_POLICY = {
|
export const DATA_TRANSFER_POLICY = {
|
||||||
allowed_downloads: ["original_generation", "jpg", "png"],
|
allowed_downloads: ["original_generation", "jpg", "png"],
|
||||||
application_backup: false,
|
application_backup: false,
|
||||||
@@ -219,9 +225,7 @@ export function initializeLocalDataRoot(input: {
|
|||||||
|
|
||||||
const createdRoot = !existsSync(validation.normalized_path);
|
const createdRoot = !existsSync(validation.normalized_path);
|
||||||
try {
|
try {
|
||||||
for (const directory of fixedDirectories) {
|
ensureLocalDataRuntimeDirectories(validation.normalized_path);
|
||||||
mkdirSync(join(validation.normalized_path, directory), { recursive: true });
|
|
||||||
}
|
|
||||||
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
|
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
|
||||||
const configuration: InstanceConfiguration = {
|
const configuration: InstanceConfiguration = {
|
||||||
data_root: validation.normalized_path,
|
data_root: validation.normalized_path,
|
||||||
|
|||||||
+11
-5
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
import { LatestExportService } from "./latest-exports.js";
|
import { LatestExportService } from "./latest-exports.js";
|
||||||
import { CreditService } from "./credits.js";
|
import { CreditService } from "./credits.js";
|
||||||
@@ -16,8 +16,8 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
|||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
import { ModelConfigurationService } from "./model-configuration.js";
|
import { ModelConfigurationService, portableRuntimeModelCandidates } from "./model-configuration.js";
|
||||||
import { MockAmapAdapter } from "./amap-adapter.js";
|
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||||
import { StickerReleaseService } from "./sticker-releases.js";
|
import { StickerReleaseService } from "./sticker-releases.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
|
||||||
@@ -30,14 +30,17 @@ let latestExports: LatestExportService | undefined;
|
|||||||
let models: ModelConfigurationService | undefined;
|
let models: ModelConfigurationService | undefined;
|
||||||
let recentAssets: RecentAssetService | undefined;
|
let recentAssets: RecentAssetService | undefined;
|
||||||
let stickers: StickerReleaseService | undefined;
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
try {
|
try {
|
||||||
|
amap = clients.amap;
|
||||||
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
.digest();
|
.digest();
|
||||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
|
ensureLocalDataRuntimeDirectories(dataRoot);
|
||||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
registration = new RegistrationService({
|
registration = new RegistrationService({
|
||||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
@@ -53,10 +56,12 @@ if (credentialChannelEnabled) {
|
|||||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||||
stickers = new StickerReleaseService({ databasePath, storage });
|
stickers = new StickerReleaseService({ databasePath, storage });
|
||||||
latestExports = new LatestExportService({ databasePath, storage });
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
models = new ModelConfigurationService({ database: registration.database });
|
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
||||||
recentAssets = new RecentAssetService({ database: registration.database });
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
amap.dispose?.();
|
||||||
|
amap = new MockAmapAdapter();
|
||||||
stickers?.close();
|
stickers?.close();
|
||||||
stickers = undefined;
|
stickers = undefined;
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
@@ -92,7 +97,7 @@ const adminDiagnostics = adminServicesStorage
|
|||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
||||||
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
||||||
amap: new MockAmapAdapter(),
|
amap,
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
...(latestExports ? { latestExports } : {}),
|
...(latestExports ? { latestExports } : {}),
|
||||||
@@ -115,6 +120,7 @@ if (controlPipeIndex >= 0) {
|
|||||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
amap.dispose?.();
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
credits?.close();
|
credits?.close();
|
||||||
projects?.close();
|
projects?.close();
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const defaultErrorMapping: Record<string, string> = {
|
|||||||
upstream_timeout: "upstream_timeout",
|
upstream_timeout: "upstream_timeout",
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedCandidates: ModelConfigCandidate[] = [
|
const defaultSeedCandidates: ModelConfigCandidate[] = [
|
||||||
{
|
{
|
||||||
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
||||||
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||||
@@ -138,6 +138,42 @@ const seedCandidates: ModelConfigCandidate[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const portableRuntimeModelCandidates: ModelConfigCandidate[] = [
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[0]!,
|
||||||
|
display_name: "Gemini 3.1 Flash Image",
|
||||||
|
route_profile: {
|
||||||
|
endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "gemini-openai-chat-v1",
|
||||||
|
provider_model_id: "gemini-3.1-flash-image",
|
||||||
|
},
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
contract_evidence_ref: "contract:wp7-02:gemini-3.1-flash-image:v7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[1]!,
|
||||||
|
enabled: false,
|
||||||
|
route_profile: { endpoint: "https://oneapi.intelligrow.cn/unsupported", mode: "disabled" },
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "unverified",
|
||||||
|
contract_evidence_ref: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[2]!,
|
||||||
|
route_profile: {
|
||||||
|
endpoint: "https://oneapi.intelligrow.cn/v1/images/generations",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "openai-images-v1",
|
||||||
|
reference_endpoint: "https://oneapi.intelligrow.cn/v1/images/edits",
|
||||||
|
},
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
contract_evidence_ref: "contract:wp7-02:gpt-image-2:v2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function stableJson(value: unknown): string {
|
function stableJson(value: unknown): string {
|
||||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
@@ -207,17 +243,20 @@ export interface ModelConfigurationServiceOptions {
|
|||||||
clock?: () => number;
|
clock?: () => number;
|
||||||
database: BetterSqlite3.Database;
|
database: BetterSqlite3.Database;
|
||||||
onChanged?: (configSetVersion: number) => void;
|
onChanged?: (configSetVersion: number) => void;
|
||||||
|
seedCandidates?: ModelConfigCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModelConfigurationService {
|
export class ModelConfigurationService {
|
||||||
readonly database: BetterSqlite3.Database;
|
readonly database: BetterSqlite3.Database;
|
||||||
readonly #clock: () => number;
|
readonly #clock: () => number;
|
||||||
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
||||||
|
readonly #seedCandidates: ModelConfigCandidate[];
|
||||||
|
|
||||||
constructor(options: ModelConfigurationServiceOptions) {
|
constructor(options: ModelConfigurationServiceOptions) {
|
||||||
this.database = options.database;
|
this.database = options.database;
|
||||||
this.#clock = options.clock ?? Date.now;
|
this.#clock = options.clock ?? Date.now;
|
||||||
this.#onChanged = options.onChanged;
|
this.#onChanged = options.onChanged;
|
||||||
|
this.#seedCandidates = structuredClone(options.seedCandidates ?? defaultSeedCandidates);
|
||||||
this.ensureSchema();
|
this.ensureSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +542,7 @@ export class ModelConfigurationService {
|
|||||||
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
||||||
if (current) return;
|
if (current) return;
|
||||||
const seed = this.database.transaction(() => {
|
const seed = this.database.transaction(() => {
|
||||||
validateModelConfigurationCandidateSet(seedCandidates);
|
validateModelConfigurationCandidateSet(this.#seedCandidates);
|
||||||
const now = this.#clock();
|
const now = this.#clock();
|
||||||
const setId = randomUUID();
|
const setId = randomUUID();
|
||||||
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
||||||
@@ -520,7 +559,9 @@ export class ModelConfigurationService {
|
|||||||
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||||
VALUES (?, ?, 1, ?, ?, ?)
|
VALUES (?, ?, 1, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
for (const candidate of seedCandidates) {
|
for (const candidate of this.#seedCandidates) {
|
||||||
|
const contractStatus = candidate.contract_validation_status ?? "unverified";
|
||||||
|
const contractEvidenceRef = contractStatus === "unverified" ? null : candidate.contract_evidence_ref ?? null;
|
||||||
const routeProfileId = profileRef("route", candidate.route_profile);
|
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||||
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||||
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
@@ -530,12 +571,14 @@ export class ModelConfigurationService {
|
|||||||
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||||
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||||
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
||||||
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
|
candidate.prompt_max_length, candidate.safety_source, contractStatus, contractEvidenceRef, fingerprint(candidate), now);
|
||||||
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||||
|
const available = candidate.enabled && contractStatus === "verified";
|
||||||
|
const runtimeReason = !candidate.enabled ? "configured_disabled" : available ? "available" : "contract_unverified";
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
VALUES (?, 0, 'contract_unverified', ?, 0)
|
VALUES (?, ?, ?, ?, 0)
|
||||||
`).run(candidate.model_id, now);
|
`).run(candidate.model_id, available ? 1 : 0, runtimeReason, now);
|
||||||
}
|
}
|
||||||
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
|
|
||||||
|
import { MockAmapAdapter, RealAmapAdapter } from "./amap-adapter.js";
|
||||||
|
|
||||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
||||||
|
|
||||||
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
||||||
@@ -13,7 +15,7 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
|||||||
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||||
throw new Error("API credential channel contains an unexpected credential scope.");
|
throw new Error("API credential channel contains an unexpected credential scope.");
|
||||||
}
|
}
|
||||||
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
|
if (expected.some((name) => typeof parsed[name] !== "string")) {
|
||||||
throw new Error("API credential channel contains an invalid credential value.");
|
throw new Error("API credential channel contains an invalid credential value.");
|
||||||
}
|
}
|
||||||
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
|
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
|
||||||
@@ -25,11 +27,16 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
try {
|
||||||
const adminPepperValue = credentials["Dada/P0A/admin/pepper"];
|
const adminPepper = credentials["Dada/P0A/admin/pepper"];
|
||||||
|
if (!adminPepper) throw new Error("admin_pepper_not_configured");
|
||||||
|
return {
|
||||||
|
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
|
||||||
|
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||||
if (!configured) throw new Error("API credential client initialization failed.");
|
}
|
||||||
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = {
|
|||||||
|
|
||||||
export type ReverseGeocodeResponse = {
|
export type ReverseGeocodeResponse = {
|
||||||
"formatted_value": string;
|
"formatted_value": string;
|
||||||
"service_mode": "mock";
|
"service_mode": "mock" | "real";
|
||||||
"status": "resolved";
|
"status": "resolved";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async function checkSupport() {
|
|||||||
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
|
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
|
||||||
supportedValue.textContent = supportedLabel(result.supported_browsers);
|
supportedValue.textContent = supportedLabel(result.supported_browsers);
|
||||||
window.dispatchEvent(new CustomEvent("dada:support-ready"));
|
window.dispatchEvent(new CustomEvent("dada:support-ready"));
|
||||||
|
window.location.replace(window.location.pathname.startsWith("/admin") ? "/admin" : "/app");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showBlocked(
|
showBlocked(
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface GenerationAdapterRequest {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
referenceAssetIds: readonly string[];
|
referenceAssetIds: readonly string[];
|
||||||
|
referenceImages?: readonly {
|
||||||
|
assetId: string;
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedGenerationOutput {
|
export interface NormalizedGenerationOutput {
|
||||||
@@ -27,6 +32,7 @@ export type GenerationAdapterResult =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface GenerationAdapter {
|
export interface GenerationAdapter {
|
||||||
|
dispose?(): void;
|
||||||
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||||
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { GenerationAdapter } from "./ai-adapter-contract.js";
|
||||||
|
|
||||||
|
export type AiRuntimeProbeResult =
|
||||||
|
| {
|
||||||
|
code: "ai_probe_passed";
|
||||||
|
mime_type: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
pixel_height: number;
|
||||||
|
pixel_width: number;
|
||||||
|
real_calls: 1;
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
code: "ai_probe_failed";
|
||||||
|
error_category: string;
|
||||||
|
real_calls: 1;
|
||||||
|
success: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runAiRuntimeProbe(adapter: GenerationAdapter): Promise<AiRuntimeProbeResult> {
|
||||||
|
const result = await adapter.start({
|
||||||
|
configSnapshot: { probe: true },
|
||||||
|
generationId: "00000000-0000-4000-8000-000000000002",
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt: "生成一张简洁的红蓝几何色块测试图,不含文字。",
|
||||||
|
ratio: "1:1",
|
||||||
|
referenceAssetIds: [],
|
||||||
|
});
|
||||||
|
if (result.status === "failed") {
|
||||||
|
return { code: "ai_probe_failed", error_category: result.category, real_calls: 1, success: false };
|
||||||
|
}
|
||||||
|
if (result.status !== "completed" || result.outputs.length !== 1) {
|
||||||
|
return { code: "ai_probe_failed", error_category: "gateway_contract_invalid", real_calls: 1, success: false };
|
||||||
|
}
|
||||||
|
const output = result.outputs[0]!;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
code: "ai_probe_passed",
|
||||||
|
mime_type: output.mimeType,
|
||||||
|
pixel_height: output.pixelHeight,
|
||||||
|
pixel_width: output.pixelWidth,
|
||||||
|
real_calls: 1,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
output.bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import type BetterSqlite3 from "better-sqlite3";
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
import type { GenerationAdapter, GenerationAdapterRequest, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||||
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||||
@@ -91,7 +91,8 @@ export class GenerationProcessor {
|
|||||||
this.clock = input.clock ?? Date.now;
|
this.clock = input.clock ?? Date.now;
|
||||||
this.dataRoot = resolve(input.dataRoot);
|
this.dataRoot = resolve(input.dataRoot);
|
||||||
this.workerId = input.workerId;
|
this.workerId = input.workerId;
|
||||||
this.database = new Database(input.databasePath);
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
configureWorkerDatabase(this.database);
|
configureWorkerDatabase(this.database);
|
||||||
this.migrate();
|
this.migrate();
|
||||||
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||||
@@ -119,6 +120,7 @@ export class GenerationProcessor {
|
|||||||
.run("worker_stopped", now, this.workerId);
|
.run("worker_stopped", now, this.workerId);
|
||||||
});
|
});
|
||||||
this.gatewayBalance.close();
|
this.gatewayBalance.close();
|
||||||
|
this.adapter.dispose?.();
|
||||||
this.database.close();
|
this.database.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +145,12 @@ export class GenerationProcessor {
|
|||||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||||
`).all(generationId) as Array<{ managed_file_id: string }>;
|
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||||
let adapterResult: GenerationAdapterResult;
|
let adapterResult: GenerationAdapterResult;
|
||||||
|
let referenceImages: NonNullable<GenerationAdapterRequest["referenceImages"]>;
|
||||||
|
try {
|
||||||
|
referenceImages = this.loadReferenceImages(references.map((row) => row.managed_file_id));
|
||||||
|
} catch {
|
||||||
|
return this.completeFailure(job, "reference_invalid", "reference_load_failed");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (job.upstream_job_reference) {
|
if (job.upstream_job_reference) {
|
||||||
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
||||||
@@ -155,10 +163,13 @@ export class GenerationProcessor {
|
|||||||
prompt: job.prompt,
|
prompt: job.prompt,
|
||||||
ratio: job.ratio,
|
ratio: job.ratio,
|
||||||
referenceAssetIds: references.map((row) => row.managed_file_id),
|
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||||
|
referenceImages,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
||||||
|
} finally {
|
||||||
|
for (const reference of referenceImages) reference.bytes.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||||
@@ -174,6 +185,29 @@ export class GenerationProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadReferenceImages(referenceAssetIds: string[]): NonNullable<GenerationAdapterRequest["referenceImages"]> {
|
||||||
|
return referenceAssetIds.map((assetId) => {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT relative_path, mime_type FROM managed_files
|
||||||
|
WHERE file_id = ? AND file_kind = 'reference' AND status = 'committed'
|
||||||
|
`).get(assetId) as { mime_type: string; relative_path: string } | undefined;
|
||||||
|
if (!row || !["image/jpeg", "image/png", "image/webp"].includes(row.mime_type) || isAbsolute(row.relative_path)) {
|
||||||
|
throw new Error("reference_invalid");
|
||||||
|
}
|
||||||
|
const path = resolve(this.dataRoot, row.relative_path);
|
||||||
|
const child = relative(this.dataRoot, path);
|
||||||
|
if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)
|
||||||
|
|| !existsSync(path) || !statSync(path).isFile()) {
|
||||||
|
throw new Error("reference_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
bytes: readFileSync(path),
|
||||||
|
mimeType: row.mime_type as "image/jpeg" | "image/png" | "image/webp",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private claim(generationId: string) {
|
private claim(generationId: string) {
|
||||||
return this.immediate(() => {
|
return this.immediate(() => {
|
||||||
const row = this.readJob(generationId);
|
const row = this.readJob(generationId);
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GenerationAdapter,
|
||||||
|
GenerationAdapterRequest,
|
||||||
|
GenerationAdapterResult,
|
||||||
|
NormalizedGenerationOutput,
|
||||||
|
} from "./ai-adapter-contract.js";
|
||||||
|
import { gptImageRequestSizeForRatio, normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||||
|
|
||||||
|
const geminiProductModelId = "gemini-3.1-flash-image-preview";
|
||||||
|
const geminiProviderModelId = "gemini-3.1-flash-image";
|
||||||
|
const gptImageModelId = "gpt-image-2";
|
||||||
|
const geminiEndpoint = "https://oneapi.intelligrow.cn/v1/chat/completions";
|
||||||
|
const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
||||||
|
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||||
|
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||||
|
const requestTimeoutMilliseconds = 180_000;
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
class OneApiRuntimeError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly category: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | "upstream_failed" | "upstream_timeout" | "unknown_non_retryable",
|
||||||
|
readonly sourceCategory: string,
|
||||||
|
) {
|
||||||
|
super(sourceCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(error: unknown): GenerationAdapterResult {
|
||||||
|
if (error instanceof OneApiRuntimeError) {
|
||||||
|
return { category: error.category, sourceCategory: error.sourceCategory, status: "failed" };
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return { category: "upstream_timeout", sourceCategory: "upstream_timeout", status: "failed" };
|
||||||
|
}
|
||||||
|
return { category: "upstream_failed", sourceCategory: "upstream_failed", status: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHttpFailure(status: number) {
|
||||||
|
if (status === 408 || status === 504) return new OneApiRuntimeError("upstream_timeout", `upstream_http_${status}`);
|
||||||
|
if (status === 429) return new OneApiRuntimeError("gateway_balance_insufficient", "upstream_http_429");
|
||||||
|
if (status >= 500) return new OneApiRuntimeError("upstream_failed", `upstream_http_${status}`);
|
||||||
|
if (status === 400 || status === 404 || status === 422) return new OneApiRuntimeError("gateway_contract_invalid", `upstream_http_${status}`);
|
||||||
|
return new OneApiRuntimeError("unknown_non_retryable", `upstream_http_${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedJson(response: Response) {
|
||||||
|
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||||
|
}
|
||||||
|
if (!response.body) throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_empty");
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) break;
|
||||||
|
const chunk = Buffer.from(next.value);
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maximumResponseBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_invalid");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const chunk of chunks) chunk.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiImage(response: unknown) {
|
||||||
|
if (!response || typeof response !== "object" || !("choices" in response) || !Array.isArray(response.choices)) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_shape_invalid");
|
||||||
|
}
|
||||||
|
const choice = response.choices[0];
|
||||||
|
const content = choice && typeof choice === "object" && "message" in choice && choice.message && typeof choice.message === "object"
|
||||||
|
&& "content" in choice.message && typeof choice.message.content === "string" ? choice.message.content : "";
|
||||||
|
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
|
||||||
|
if (matches.length !== 1) throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||||
|
return { bytes: Buffer.from(matches[0]![2]!, "base64"), declaredMimeType: matches[0]![1]!.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGptImage(response: unknown) {
|
||||||
|
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data)
|
||||||
|
|| response.data.length !== 1 || !response.data[0] || typeof response.data[0] !== "object"
|
||||||
|
|| !("b64_json" in response.data[0]) || typeof response.data[0].b64_json !== "string") {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||||
|
}
|
||||||
|
return { bytes: Buffer.from(response.data[0].b64_json, "base64"), declaredMimeType: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeOutput(bytes: Buffer, declaredMimeType: string | undefined, ratio: GenerationAdapterRequest["ratio"]): Promise<NormalizedGenerationOutput> {
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(bytes, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
|
||||||
|
const mimeType = metadata.format === "png" ? "image/png" : metadata.format === "jpeg" ? "image/jpeg" : metadata.format === "webp" ? "image/webp" : undefined;
|
||||||
|
if (!mimeType || !metadata.width || !metadata.height || (declaredMimeType && declaredMimeType !== mimeType)) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||||
|
}
|
||||||
|
const normalized = await normalizeImageOutputToRatio({ bytes, mimeType, pixelHeight: metadata.height, pixelWidth: metadata.width, ratio });
|
||||||
|
return { bytes: normalized.bytes, mimeType: normalized.mimeType, pixelHeight: normalized.pixelHeight, pixelWidth: normalized.pixelWidth };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OneApiRuntimeError) throw error;
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequest(request: GenerationAdapterRequest) {
|
||||||
|
if (!request.prompt.trim() || request.prompt.length > 1_000) throw new OneApiRuntimeError("gateway_contract_invalid", "prompt_invalid");
|
||||||
|
const references = request.referenceImages ?? [];
|
||||||
|
if (references.length !== request.referenceAssetIds.length || references.length > 2) {
|
||||||
|
throw new OneApiRuntimeError("reference_invalid", "reference_count_invalid");
|
||||||
|
}
|
||||||
|
const totalBytes = references.reduce((total, reference) => total + reference.bytes.length, 0);
|
||||||
|
if (totalBytes > 20 * 1024 * 1024 || references.some((reference) => reference.bytes.length === 0 || reference.bytes.length > 10 * 1024 * 1024)) {
|
||||||
|
throw new OneApiRuntimeError("reference_invalid", "reference_size_invalid");
|
||||||
|
}
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequest(request: GenerationAdapterRequest) {
|
||||||
|
const references = validateRequest(request);
|
||||||
|
if (request.modelId === geminiProductModelId) {
|
||||||
|
const content = references.length === 0
|
||||||
|
? request.prompt
|
||||||
|
: [
|
||||||
|
{ text: request.prompt, type: "text" },
|
||||||
|
...references.map((reference) => ({
|
||||||
|
image_url: { url: `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}` },
|
||||||
|
type: "image_url",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
body: JSON.stringify({
|
||||||
|
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||||
|
messages: [{ content, role: "user" }],
|
||||||
|
model: geminiProviderModelId,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
contentType: "application/json",
|
||||||
|
endpoint: geminiEndpoint,
|
||||||
|
parser: extractGeminiImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.modelId !== gptImageModelId) throw new OneApiRuntimeError("model_disabled", "model_not_supported");
|
||||||
|
if (references.length > 0) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("model", gptImageModelId);
|
||||||
|
form.append("prompt", request.prompt);
|
||||||
|
form.append("response_format", "b64_json");
|
||||||
|
form.append("size", gptImageRequestSizeForRatio(request.ratio));
|
||||||
|
references.forEach((reference, index) => form.append("image[]", new Blob([reference.bytes], { type: reference.mimeType }), `reference-${index + 1}.png`));
|
||||||
|
return { body: form, contentType: undefined, endpoint: gptImageReferenceEndpoint, parser: extractGptImage };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: JSON.stringify({ model: gptImageModelId, prompt: request.prompt, response_format: "b64_json", size: gptImageRequestSizeForRatio(request.ratio) }),
|
||||||
|
contentType: "application/json",
|
||||||
|
endpoint: gptImageEndpoint,
|
||||||
|
parser: extractGptImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OneApiGenerationAdapter implements GenerationAdapter {
|
||||||
|
private readonly credential: Buffer;
|
||||||
|
private readonly fetchImpl: FetchLike;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(input: { credential: Buffer; fetch?: FetchLike }) {
|
||||||
|
if (input.credential.length < 8) throw new Error("ai_gateway_credential_invalid");
|
||||||
|
this.credential = Buffer.from(input.credential);
|
||||||
|
this.fetchImpl = input.fetch ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||||
|
if (this.disposed) return { category: "upstream_failed", sourceCategory: "adapter_disposed", status: "failed" };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), requestTimeoutMilliseconds);
|
||||||
|
let sourceBytes: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
const providerRequest = buildRequest(request);
|
||||||
|
const headers = new Headers({ authorization: `Bearer ${this.credential.toString("utf8")}` });
|
||||||
|
if (providerRequest.contentType) headers.set("content-type", providerRequest.contentType);
|
||||||
|
const response = await this.fetchImpl(providerRequest.endpoint, {
|
||||||
|
body: providerRequest.body,
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
redirect: "error",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw mapHttpFailure(response.status);
|
||||||
|
const parsed = await readBoundedJson(response);
|
||||||
|
const extracted = providerRequest.parser(parsed);
|
||||||
|
sourceBytes = extracted.bytes;
|
||||||
|
const output = await normalizeOutput(sourceBytes, extracted.declaredMimeType, request.ratio);
|
||||||
|
return { outputs: [output], status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
return failure(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
sourceBytes?.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.credential.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
|||||||
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||||
throw new Error("Worker credential channel contains an unexpected credential scope.");
|
throw new Error("Worker credential channel contains an unexpected credential scope.");
|
||||||
}
|
}
|
||||||
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
|
if (expected.some((name) => typeof parsed[name] !== "string")) {
|
||||||
throw new Error("Worker credential channel contains an invalid credential value.");
|
throw new Error("Worker credential channel contains an invalid credential value.");
|
||||||
}
|
}
|
||||||
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
|
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
|
||||||
@@ -25,9 +25,13 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
||||||
const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0);
|
const value = credentials["Dada/P0A/worker/ai-gateway"];
|
||||||
|
try {
|
||||||
|
if (!value) throw new Error("worker_ai_gateway_not_configured");
|
||||||
|
return { aiGatewayCredential: Buffer.from(value, "utf8") };
|
||||||
|
} finally {
|
||||||
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
||||||
if (!configured) throw new Error("Worker credential client initialization failed.");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { parentPort } from "node:worker_threads";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||||
|
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||||
|
import { GenerationProcessor } from "./generation-processor.js";
|
||||||
|
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||||
import { RetentionCleanup } from "./retention-cleanup.js";
|
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||||
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||||
@@ -21,8 +24,25 @@ if (workerPort) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||||
initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||||
|
let adapter: OneApiGenerationAdapter | undefined;
|
||||||
|
let probeResult: Awaited<ReturnType<typeof runAiRuntimeProbe>> | { code: "ai_probe_failed"; error_category: "upstream_failed"; real_calls: 0; success: false };
|
||||||
|
try {
|
||||||
|
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
|
||||||
|
probeResult = await runAiRuntimeProbe(adapter);
|
||||||
|
} catch {
|
||||||
|
probeResult = { code: "ai_probe_failed", error_category: "upstream_failed", real_calls: 0, success: false };
|
||||||
|
} finally {
|
||||||
|
credentialClient.aiGatewayCredential.fill(0);
|
||||||
|
adapter?.dispose();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolveWrite, rejectWrite) => {
|
||||||
|
process.stdout.write(JSON.stringify(probeResult), (error) => error ? rejectWrite(error) : resolveWrite());
|
||||||
|
});
|
||||||
|
process.exit(probeResult.success ? 0 : 2);
|
||||||
|
} else if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||||
|
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||||
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||||
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
@@ -31,11 +51,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
let retention: RetentionCleanup | undefined;
|
let retention: RetentionCleanup | undefined;
|
||||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let processor: GenerationProcessor | undefined;
|
||||||
|
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||||
clearInterval(keepAlive);
|
clearInterval(keepAlive);
|
||||||
if (retentionTimer) clearInterval(retentionTimer);
|
if (retentionTimer) clearInterval(retentionTimer);
|
||||||
retention?.close();
|
retention?.close();
|
||||||
projectCleanup?.close();
|
projectCleanup?.close();
|
||||||
|
if (generationTimer) clearInterval(generationTimer);
|
||||||
|
processor?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
let storageStatus: "active" | "unavailable" = "active";
|
let storageStatus: "active" | "unavailable" = "active";
|
||||||
@@ -45,6 +69,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
storage = new WorkerStorageStatus(databasePath);
|
storage = new WorkerStorageStatus(databasePath);
|
||||||
retention = new RetentionCleanup({ databasePath });
|
retention = new RetentionCleanup({ databasePath });
|
||||||
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||||
|
let adapter: OneApiGenerationAdapter;
|
||||||
|
try {
|
||||||
|
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
|
||||||
|
} finally {
|
||||||
|
credentialClient.aiGatewayCredential.fill(0);
|
||||||
|
}
|
||||||
|
processor = new GenerationProcessor({ adapter, dataRoot, databasePath, workerId: `portable-oneapi-worker-${process.pid}` });
|
||||||
const runRetentionCleanup = () => {
|
const runRetentionCleanup = () => {
|
||||||
try {
|
try {
|
||||||
retention?.purgeExpired();
|
retention?.purgeExpired();
|
||||||
@@ -71,6 +102,7 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
});
|
});
|
||||||
logger.write({ error_category: "none", status_category: "ready" });
|
logger.write({ error_category: "none", status_category: "ready" });
|
||||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||||
|
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
||||||
} catch {
|
} catch {
|
||||||
storageStatus = "unavailable";
|
storageStatus = "unavailable";
|
||||||
control.reportStatus("storage_unavailable");
|
control.reportStatus("storage_unavailable");
|
||||||
|
|||||||
@@ -5621,11 +5621,21 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"service_mode": {
|
"service_mode": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
"enum": [
|
"enum": [
|
||||||
"mock"
|
"mock"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"real"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"resolved"
|
"resolved"
|
||||||
|
|||||||
+8
-1
@@ -111,7 +111,14 @@
|
|||||||
"test:wp7-02:controlled": "node scripts/run-wp7-02-validation.mjs --controlled-real",
|
"test:wp7-02:controlled": "node scripts/run-wp7-02-validation.mjs --controlled-real",
|
||||||
"review:wp7-02": "node scripts/record-wp7-02-manual-review.mjs",
|
"review:wp7-02": "node scripts/record-wp7-02-manual-review.mjs",
|
||||||
"test:wp7-03": "node scripts/run-wp7-03-validation.mjs --phase green",
|
"test:wp7-03": "node scripts/run-wp7-03-validation.mjs --phase green",
|
||||||
"test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red"
|
"test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red",
|
||||||
|
"test:wp7-04": "node scripts/run-wp7-04-validation.mjs",
|
||||||
|
"test:wp7-05": "node scripts/run-wp7-05-validation.mjs",
|
||||||
|
"test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs tests/package/wp7-05-coverage.test.mjs",
|
||||||
|
"test:wp7-06": "node scripts/run-wp7-06-validation.mjs",
|
||||||
|
"test:wp7-06:unit": "node --test tests/package/wp7-06-prefreeze.test.mjs",
|
||||||
|
"test:wp7-07": "node scripts/run-wp7-07-validation.mjs",
|
||||||
|
"test:wp7-07:unit": "node --test tests/package/wp7-07-final-release.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const ReverseGeocodeRequestSchema = Type.Object({
|
|||||||
|
|
||||||
export const ReverseGeocodeResponseSchema = Type.Object({
|
export const ReverseGeocodeResponseSchema = Type.Object({
|
||||||
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
|
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
|
||||||
service_mode: Type.Literal("mock"),
|
service_mode: Type.Union([Type.Literal("mock"), Type.Literal("real")]),
|
||||||
status: Type.Literal("resolved"),
|
status: Type.Literal("resolved"),
|
||||||
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
||||||
|
import { validateFinalReleaseRecord } from "./lib/wp7-07-final-release.mjs";
|
||||||
|
|
||||||
const outputIndex = process.argv.indexOf("--output");
|
const outputIndex = process.argv.indexOf("--output");
|
||||||
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
|
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
|
||||||
const result = await buildAndValidatePortablePackage({ outputRoot });
|
const previousRelease = JSON.parse(readFileSync(resolve("RELEASE.json"), "utf8"));
|
||||||
|
const releaseRecord = validateFinalReleaseRecord({
|
||||||
|
...previousRelease,
|
||||||
|
buildCommit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||||
|
maintenanceFromCommit: previousRelease.buildCommit,
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const result = await buildAndValidatePortablePackage({ outputRoot, releaseRecord });
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
package: result.packageManifest.package_name,
|
package: result.packageManifest.package_name,
|
||||||
sha256: result.packageManifest.zip_sha256,
|
sha256: result.packageManifest.zip_sha256,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function copyApplication(source, destination, runtimeDependencies) {
|
|||||||
|
|
||||||
function buildArtifacts(stagingRoot) {
|
function buildArtifacts(stagingRoot) {
|
||||||
debug("build workspace artifacts");
|
debug("build workspace artifacts");
|
||||||
run("pnpm", ["--filter", "@dada/shared-contracts", "build"]);
|
run("pnpm", ["build:workspace-packages"]);
|
||||||
run("pnpm", ["--filter", "@dada/web", "build"]);
|
run("pnpm", ["--filter", "@dada/web", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/api", "build"]);
|
run("pnpm", ["--filter", "@dada/api", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
||||||
@@ -208,7 +208,7 @@ async function waitForHealth(child) {
|
|||||||
throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError });
|
throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyExtractedPackage(zipPath, packageName) {
|
export async function verifyExtractedPackage(zipPath, packageName, expectedSupport) {
|
||||||
const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-"));
|
const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-"));
|
||||||
try {
|
try {
|
||||||
const escapedZip = zipPath.replaceAll("'", "''");
|
const escapedZip = zipPath.replaceAll("'", "''");
|
||||||
@@ -235,21 +235,37 @@ async function verifyExtractedPackage(zipPath, packageName) {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const health = await waitForHealth(api);
|
const health = await waitForHealth(api);
|
||||||
|
const brands = [
|
||||||
|
{ brand: "Not_A Brand", version: "99" },
|
||||||
|
{ brand: "Chromium", version: String(expectedSupport.major) },
|
||||||
|
{ brand: expectedSupport.brand, version: String(expectedSupport.major) },
|
||||||
|
];
|
||||||
|
const fullVersionList = [
|
||||||
|
{ brand: "Not_A Brand", version: "99.0.0.0" },
|
||||||
|
{ brand: "Chromium", version: expectedSupport.fullVersion },
|
||||||
|
{ brand: expectedSupport.brand, version: expectedSupport.fullVersion },
|
||||||
|
];
|
||||||
|
const serializeBrands = (values) => values.map(({ brand, version }) => `"${brand}";v="${version}"`).join(", ");
|
||||||
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
|
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
brands: [{ brand: "Google Chrome", version: "150" }],
|
brands,
|
||||||
full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }],
|
full_version_list: fullVersionList,
|
||||||
platform: "Windows",
|
platform: "Windows",
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"sec-ch-ua": '"Google Chrome";v="150"',
|
host: `127.0.0.1:${fixedPort}`,
|
||||||
"sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"',
|
origin: `http://127.0.0.1:${fixedPort}`,
|
||||||
|
"sec-ch-ua": serializeBrands(brands),
|
||||||
|
"sec-ch-ua-full-version-list": serializeBrands(fullVersionList),
|
||||||
"sec-ch-ua-platform": '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
},
|
},
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (releaseGate.status !== 426) throw new Error(`Candidate RELEASE.json unexpectedly passed with ${releaseGate.status}.`);
|
if (releaseGate.status !== expectedSupport.statusCode) {
|
||||||
|
const responseBody = await releaseGate.text();
|
||||||
|
throw new Error(`Packaged RELEASE.json support gate returned ${releaseGate.status}; expected ${expectedSupport.statusCode}: ${responseBody}`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" },
|
api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" },
|
||||||
native,
|
native,
|
||||||
@@ -291,7 +307,7 @@ function scanPackage(packageDirectory) {
|
|||||||
return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" };
|
return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }) {
|
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot, releaseRecord }) {
|
||||||
if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) {
|
if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) {
|
||||||
throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64.");
|
throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64.");
|
||||||
}
|
}
|
||||||
@@ -320,7 +336,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
debug("copy API application");
|
debug("copy API application");
|
||||||
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
|
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
|
||||||
debug("copy Worker application");
|
debug("copy Worker application");
|
||||||
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
|
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3", "sharp"]);
|
||||||
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
||||||
mkdirSync(sharedDestination, { recursive: true });
|
mkdirSync(sharedDestination, { recursive: true });
|
||||||
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
||||||
@@ -351,7 +367,8 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||||
|
|
||||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||||
writeJson(join(packageDirectory, "RELEASE.json"), {
|
const finalRelease = releaseRecord !== undefined;
|
||||||
|
writeJson(join(packageDirectory, "RELEASE.json"), releaseRecord ?? {
|
||||||
app_version: appVersion,
|
app_version: appVersion,
|
||||||
browsers: [],
|
browsers: [],
|
||||||
build_commit: commit,
|
build_commit: commit,
|
||||||
@@ -360,16 +377,20 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
windows_build: null,
|
windows_build: null,
|
||||||
});
|
});
|
||||||
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
|
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
|
||||||
"Dada P0-A candidate package",
|
finalRelease ? "Dada P0-A first-version portable package" : "Dada P0-A candidate package",
|
||||||
"",
|
"",
|
||||||
"This candidate is unsigned and is not a final P0-A release.",
|
finalRelease
|
||||||
|
? "This unsigned first-version package passed the local P0-A release gates recorded in RELEASE.json."
|
||||||
|
: "This candidate is unsigned and is not a final P0-A release.",
|
||||||
"Verify the adjacent SHA-256 file before first launch.",
|
"Verify the adjacent SHA-256 file before first launch.",
|
||||||
"Windows SmartScreen may warn on first launch because the executable is unsigned.",
|
"Windows SmartScreen may warn on first launch because the executable is unsigned.",
|
||||||
"For an antivirus alert, compare the package hash with the Gitea build record.",
|
"For an antivirus alert, compare the package hash with the Gitea build record.",
|
||||||
"Do not disable antivirus protection, add broad exclusions, or skip hash verification.",
|
"Do not disable antivirus protection, add broad exclusions, or skip hash verification.",
|
||||||
"To update, exit Dada from the tray and replace the complete program directory.",
|
"To update, exit Dada from the tray and replace the complete program directory.",
|
||||||
"Dada uses 127.0.0.1:43121 and does not support LAN or remote access.",
|
"Dada uses 127.0.0.1:43121 and does not support LAN or remote access.",
|
||||||
"A final RELEASE.json is created only after WP-7 acceptance.",
|
finalRelease
|
||||||
|
? "Resend and Amap real-provider validation remain explicitly deferred and are not recorded as passed."
|
||||||
|
: "A final RELEASE.json is created only after WP-7 acceptance.",
|
||||||
"",
|
"",
|
||||||
].join("\r\n"));
|
].join("\r\n"));
|
||||||
|
|
||||||
@@ -381,7 +402,18 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
const zipSha256 = fileSha256(zipPath);
|
const zipSha256 = fileSha256(zipPath);
|
||||||
const shaPath = `${zipPath}.sha256`;
|
const shaPath = `${zipPath}.sha256`;
|
||||||
writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`);
|
writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`);
|
||||||
const processTree = await verifyExtractedPackage(zipPath, packageName);
|
const supportBrowser = finalRelease ? releaseRecord.browsers[0] : undefined;
|
||||||
|
const processTree = await verifyExtractedPackage(zipPath, packageName, finalRelease ? {
|
||||||
|
brand: supportBrowser.brand,
|
||||||
|
fullVersion: supportBrowser.fullVersion,
|
||||||
|
major: Number.parseInt(supportBrowser.fullVersion.split(".")[0], 10),
|
||||||
|
statusCode: 200,
|
||||||
|
} : {
|
||||||
|
brand: "Google Chrome",
|
||||||
|
fullVersion: "150.0.0.0",
|
||||||
|
major: 150,
|
||||||
|
statusCode: 426,
|
||||||
|
});
|
||||||
const fileEntries = listFiles(packageDirectory).files.map((path) => ({
|
const fileEntries = listFiles(packageDirectory).files.map((path) => ({
|
||||||
path: relative(packageDirectory, path).replaceAll("\\", "/"),
|
path: relative(packageDirectory, path).replaceAll("\\", "/"),
|
||||||
sha256: fileSha256(path),
|
sha256: fileSha256(path),
|
||||||
@@ -392,7 +424,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
files: fileEntries,
|
files: fileEntries,
|
||||||
fixed_port: fixedPort,
|
fixed_port: fixedPort,
|
||||||
package_name: packageName,
|
package_name: packageName,
|
||||||
release_status: "candidate_unvalidated",
|
release_status: finalRelease ? releaseRecord.releaseStatus : "candidate_unvalidated",
|
||||||
schema_version: "1.0",
|
schema_version: "1.0",
|
||||||
zip_sha256: zipSha256,
|
zip_sha256: zipSha256,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { REQUIRED_COVERAGE_UNITS } from './wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\\\\)/;
|
||||||
|
|
||||||
|
function assertSafeRelative(value, field) {
|
||||||
|
if (typeof value !== 'string' || !value || ABSOLUTE_PATH.test(value) || path.isAbsolute(value)) {
|
||||||
|
throw new Error(`WP7_05_UNSAFE_${field}`);
|
||||||
|
}
|
||||||
|
const normalized = value.replaceAll('\\', '/');
|
||||||
|
if (normalized.split('/').includes('..')) throw new Error(`WP7_05_UNSAFE_${field}`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoverageEvidence({ runId, candidateSha256, coverageUnits, viewports }) {
|
||||||
|
if (!runId || !/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error('WP7_05_INVALID_RUN_ID');
|
||||||
|
if (!/^[A-Fa-f0-9]{64}$/.test(candidateSha256 ?? '')) throw new Error('WP7_05_INVALID_CANDIDATE_HASH');
|
||||||
|
if (!Array.isArray(coverageUnits)) throw new Error('WP7_05_COVERAGE_UNITS_REQUIRED');
|
||||||
|
|
||||||
|
const byPage = new Map();
|
||||||
|
for (const unit of coverageUnits) {
|
||||||
|
if (!REQUIRED_COVERAGE_UNITS.includes(unit.page_id)) throw new Error('WP7_05_UNKNOWN_PAGE');
|
||||||
|
if (byPage.has(unit.page_id)) throw new Error('WP7_05_DUPLICATE_PAGE');
|
||||||
|
if (!Array.isArray(unit.states) || unit.states.length === 0) throw new Error('WP7_05_STATES_REQUIRED');
|
||||||
|
const states = unit.states.map((state) => ({
|
||||||
|
state: assertSafeRelative(state.state, 'STATE'),
|
||||||
|
screenshot_100pct: assertSafeRelative(state.screenshot_100pct, 'SCREENSHOT'),
|
||||||
|
screenshot_200pct: assertSafeRelative(state.screenshot_200pct, 'SCREENSHOT'),
|
||||||
|
trace: assertSafeRelative(state.trace, 'TRACE'),
|
||||||
|
}));
|
||||||
|
byPage.set(unit.page_id, { page_id: unit.page_id, states });
|
||||||
|
}
|
||||||
|
const missing = REQUIRED_COVERAGE_UNITS.filter((page) => !byPage.has(page));
|
||||||
|
if (missing.length) throw new Error(`WP7_05_MISSING_PAGES:${missing.join(',')}`);
|
||||||
|
if (!Array.isArray(viewports) || viewports.length !== 2) throw new Error('WP7_05_VIEWPORTS_REQUIRED');
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: '1.0',
|
||||||
|
task: 'TASK-WP7-05',
|
||||||
|
run_id: runId,
|
||||||
|
candidate_sha256: candidateSha256.toUpperCase(),
|
||||||
|
viewports,
|
||||||
|
coverage_units: REQUIRED_COVERAGE_UNITS.map((page) => byPage.get(page)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export const REQUIRED_COVERAGE_UNITS = Object.freeze([
|
||||||
|
'support-gate', 'user-auth', 'workspace', 'current-task', 'projects',
|
||||||
|
'project-detail', 'editor', 'export', 'credits', 'settings',
|
||||||
|
'preview-user-variant', 'admin-auth', 'admin-overview', 'admin-users',
|
||||||
|
'admin-invites', 'admin-models', 'admin-assets', 'admin-preview',
|
||||||
|
'admin-generations', 'admin-services-storage', 'admin-audit', 'system-ui',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REQUIRED_VIEWPORTS = Object.freeze([
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 },
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function blocked(code, details = {}) {
|
||||||
|
return { status: 'externally_blocked', code, ...details };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCandidateRecord(path) {
|
||||||
|
if (!path || !fs.existsSync(path)) return blocked('candidate_record_missing');
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||||
|
if (!Array.isArray(record.browsers) || record.browsers.length !== 2) {
|
||||||
|
return blocked('candidate_browser_record_incomplete');
|
||||||
|
}
|
||||||
|
const brands = new Set(record.browsers.map((browser) => browser.brand));
|
||||||
|
if (brands.size !== 2 || !brands.has('Google Chrome') || !brands.has('Microsoft Edge')) {
|
||||||
|
return blocked('candidate_browser_pair_invalid');
|
||||||
|
}
|
||||||
|
if (record.windows?.build == null || !record.candidate_package?.sha256 || !record.candidate_package?.fixed_port) {
|
||||||
|
return blocked('candidate_identity_incomplete');
|
||||||
|
}
|
||||||
|
if (record.browsers.some((browser) => !browser.full_version || !browser.major)) {
|
||||||
|
return blocked('candidate_full_version_missing');
|
||||||
|
}
|
||||||
|
return { status: 'ready', record };
|
||||||
|
} catch {
|
||||||
|
return blocked('candidate_record_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCoverageEvidence(evidence) {
|
||||||
|
if (!evidence || !Array.isArray(evidence.coverage_units)) {
|
||||||
|
return blocked('coverage_evidence_missing');
|
||||||
|
}
|
||||||
|
const actual = new Set(evidence.coverage_units.map((unit) => unit.page_id));
|
||||||
|
const missing = REQUIRED_COVERAGE_UNITS.filter((unit) => !actual.has(unit));
|
||||||
|
if (missing.length) return blocked('coverage_units_incomplete', { missing });
|
||||||
|
const missingStates = evidence.coverage_units
|
||||||
|
.filter((unit) => REQUIRED_COVERAGE_UNITS.includes(unit.page_id))
|
||||||
|
.filter((unit) => !Array.isArray(unit.states) || unit.states.length === 0)
|
||||||
|
.map((unit) => unit.page_id);
|
||||||
|
if (missingStates.length) return blocked('coverage_states_incomplete', { missingStates });
|
||||||
|
const viewportKeys = new Set((evidence.viewports ?? []).map((viewport) => JSON.stringify(viewport)));
|
||||||
|
const missingViewports = REQUIRED_VIEWPORTS.filter((viewport) => !viewportKeys.has(JSON.stringify(viewport)));
|
||||||
|
if (missingViewports.length) return blocked('candidate_viewports_incomplete', { missingViewports });
|
||||||
|
return { status: 'ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWp705Gate({ candidatePath, evidence, dependencies = {} }) {
|
||||||
|
const candidate = loadCandidateRecord(candidatePath);
|
||||||
|
if (candidate.status !== 'ready') return candidate;
|
||||||
|
const coverage = validateCoverageEvidence(evidence);
|
||||||
|
if (coverage.status !== 'ready') return coverage;
|
||||||
|
const externalBlockers = Object.entries(dependencies)
|
||||||
|
.filter(([, status]) => status === 'externally_blocked')
|
||||||
|
.map(([task]) => task);
|
||||||
|
if (externalBlockers.length) return blocked('upstream_external_blocked', { externalBlockers });
|
||||||
|
return { status: 'ready_for_execution' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const EXPECTED_TRACE_SUMMARY = Object.freeze({
|
||||||
|
acceptanceCriteria: 52,
|
||||||
|
errorCategories: 9,
|
||||||
|
featureModules: 13,
|
||||||
|
parentFamilies: 89,
|
||||||
|
penProductFrames: 18,
|
||||||
|
productContracts: 19,
|
||||||
|
requirements: 109,
|
||||||
|
tasks: 52,
|
||||||
|
testCases: 117,
|
||||||
|
uiPages: 22,
|
||||||
|
});
|
||||||
|
|
||||||
|
const REQUIRED_UPSTREAM = Object.freeze({
|
||||||
|
"TASK-WP7-01": "passed",
|
||||||
|
"TASK-WP7-02": "passed",
|
||||||
|
"TASK-WP7-03": "deferred_nonblocking_first_version",
|
||||||
|
"TASK-WP7-04": "deferred_nonblocking_first_version",
|
||||||
|
"TASK-WP7-05": "passed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const shaPattern = /^[0-9a-f]{40}$/i;
|
||||||
|
|
||||||
|
export function buildWp706PrefreezeReport({ currentCommit, releaseExists, trace, upstream }) {
|
||||||
|
if (releaseExists) throw new Error("WP7_06_RELEASE_WRITTEN_PREMATURELY");
|
||||||
|
if (!shaPattern.test(currentCommit ?? "")) throw new Error("WP7_06_CURRENT_COMMIT_INVALID");
|
||||||
|
if (trace?.status !== "passed" || !Array.isArray(trace?.errors) || trace.errors.length > 0) {
|
||||||
|
throw new Error("WP7_06_TRACE_VALIDATION_FAILED");
|
||||||
|
}
|
||||||
|
for (const [key, expected] of Object.entries(EXPECTED_TRACE_SUMMARY)) {
|
||||||
|
if (trace.summary?.[key] !== expected) throw new Error(`WP7_06_TRACE_COUNT_MISMATCH:${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [taskId, expectedStatus] of Object.entries(REQUIRED_UPSTREAM)) {
|
||||||
|
const item = upstream?.[taskId];
|
||||||
|
if (!item || !shaPattern.test(item.head ?? "")) throw new Error(`WP7_06_UPSTREAM_HEAD_INVALID:${taskId}`);
|
||||||
|
if (item.merged !== true) throw new Error(`WP7_06_UPSTREAM_NOT_MERGED:${taskId}`);
|
||||||
|
if (item.status !== expectedStatus) throw new Error(`WP7_06_UNSUPPORTED_STATUS:${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: "1.0",
|
||||||
|
task_id: "TASK-WP7-06",
|
||||||
|
status: "passed",
|
||||||
|
current_commit: currentCommit.toLowerCase(),
|
||||||
|
release_json_written: false,
|
||||||
|
trace_summary: { ...EXPECTED_TRACE_SUMMARY },
|
||||||
|
upstream: Object.fromEntries(Object.entries(REQUIRED_UPSTREAM).map(([taskId]) => [taskId, {
|
||||||
|
branch: upstream[taskId].branch,
|
||||||
|
head: upstream[taskId].head.toLowerCase(),
|
||||||
|
merged: true,
|
||||||
|
status: upstream[taskId].status,
|
||||||
|
}])),
|
||||||
|
deferred_external_tasks: Object.entries(REQUIRED_UPSTREAM)
|
||||||
|
.filter(([, status]) => status === "deferred_nonblocking_first_version")
|
||||||
|
.map(([taskId]) => taskId),
|
||||||
|
final_release_allowed: false,
|
||||||
|
next_task: "TASK-WP7-07",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EXPECTED_TRACE_SUMMARY, REQUIRED_UPSTREAM };
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
const SHA40 = /^[a-f0-9]{40}$/i;
|
||||||
|
const SHA64 = /^[a-f0-9]{64}$/i;
|
||||||
|
const VERSION = /^[1-9][0-9]*\.[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||||
|
const ABSOLUTE_PATH = /(?:[A-Za-z]:[\\/](?:Users|Documents)[\\/][^\\/"'\s]+[\\/]|\/Users\/[^/"'\s]+\/|\/home\/[^/"'\s]+\/)/;
|
||||||
|
const CREDENTIAL = /\b(?:sk|key)-[A-Za-z0-9_-]{16,}\b|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i;
|
||||||
|
const TEXT_EXTENSIONS = new Set([".cjs", ".cs", ".css", ".html", ".js", ".json", ".mjs", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml"]);
|
||||||
|
|
||||||
|
export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-04"]);
|
||||||
|
|
||||||
|
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
|
||||||
|
const record = {
|
||||||
|
appVersion,
|
||||||
|
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
|
||||||
|
buildCommit: buildCommit.toLowerCase(),
|
||||||
|
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
|
||||||
|
finalRelease: true,
|
||||||
|
fixedPort: 43121,
|
||||||
|
frozenFromCommit: frozenFromCommit.toLowerCase(),
|
||||||
|
recordedAt,
|
||||||
|
releaseStatus: "first_version_internal",
|
||||||
|
schemaVersion: "1.0",
|
||||||
|
windows: { arch: windows.arch, build: windows.build, displayVersion: windows.displayVersion },
|
||||||
|
};
|
||||||
|
return validateFinalReleaseRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFinalReleaseRecord(record) {
|
||||||
|
const errors = [];
|
||||||
|
if (record?.schemaVersion !== "1.0") errors.push("schemaVersion");
|
||||||
|
if (record?.releaseStatus !== "first_version_internal") errors.push("releaseStatus");
|
||||||
|
if (record?.finalRelease !== true) errors.push("finalRelease");
|
||||||
|
if (record?.fixedPort !== 43121) errors.push("fixedPort");
|
||||||
|
if (!SHA40.test(record?.buildCommit ?? "")) errors.push("buildCommit");
|
||||||
|
if (!SHA40.test(record?.frozenFromCommit ?? "")) errors.push("frozenFromCommit");
|
||||||
|
if (!Number.isFinite(Date.parse(record?.recordedAt ?? ""))) errors.push("recordedAt");
|
||||||
|
if (!Array.isArray(record?.deferredExternalTasks) || record.deferredExternalTasks.join("|") !== DEFERRED_EXTERNAL_TASKS.join("|")) errors.push("deferredExternalTasks");
|
||||||
|
if (record?.windows?.arch !== "x64" || !/^\d+\.\d+$/.test(record?.windows?.build ?? "")) errors.push("windows");
|
||||||
|
if (!Array.isArray(record?.browsers) || record.browsers.length !== 2) {
|
||||||
|
errors.push("browsers");
|
||||||
|
} else {
|
||||||
|
const brands = record.browsers.map(({ brand }) => brand).sort();
|
||||||
|
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
|
||||||
|
for (const browser of record.browsers) {
|
||||||
|
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
|
||||||
|
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(record);
|
||||||
|
if (ABSOLUTE_PATH.test(serialized) || CREDENTIAL.test(serialized)) errors.push("sensitiveValue");
|
||||||
|
if (errors.length > 0) throw new Error(`WP7_07_RELEASE_INVALID:${[...new Set(errors)].join(",")}`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256File(path) {
|
||||||
|
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanReleaseFiles({ roots, allowedFixturePaths = [] }) {
|
||||||
|
const allowed = new Set(allowedFixturePaths.map((value) => value.replaceAll("\\", "/")));
|
||||||
|
const findings = [];
|
||||||
|
let scannedFiles = 0;
|
||||||
|
function visit(root, current = root) {
|
||||||
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||||
|
if ([".git", ".pnpm-store", "node_modules", "bin", "obj"].includes(entry.name)) continue;
|
||||||
|
const path = join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(root, path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
scannedFiles += 1;
|
||||||
|
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
||||||
|
const logicalPath = relative(root, path).replaceAll("\\", "/");
|
||||||
|
const content = readFileSync(path, "utf8");
|
||||||
|
if (!allowed.has(logicalPath) && ABSOLUTE_PATH.test(content)) findings.push({ path: logicalPath, rule: "absolute_user_path" });
|
||||||
|
if (!allowed.has(logicalPath) && CREDENTIAL.test(content)) findings.push({ path: logicalPath, rule: "credential_shape" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const root of roots) {
|
||||||
|
if (!statSync(root).isDirectory()) throw new Error(`WP7_07_SCAN_ROOT_INVALID:${root}`);
|
||||||
|
visit(root);
|
||||||
|
}
|
||||||
|
return { findings, scanned_files: scannedFiles, status: findings.length === 0 ? "passed" : "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFinalEvidence({ packageManifest, release, releaseSha256, scan }) {
|
||||||
|
validateFinalReleaseRecord(release);
|
||||||
|
if (!SHA64.test(releaseSha256 ?? "")) throw new Error("WP7_07_RELEASE_HASH_INVALID");
|
||||||
|
if (packageManifest?.release_status !== release.releaseStatus || !SHA64.test(packageManifest?.zip_sha256 ?? "")) throw new Error("WP7_07_PACKAGE_MANIFEST_INVALID");
|
||||||
|
if (scan?.status !== "passed" || scan.findings?.length !== 0) throw new Error("WP7_07_LEAK_SCAN_FAILED");
|
||||||
|
return { release_sha256: releaseSha256.toUpperCase(), status: "passed", zip_sha256: packageManifest.zip_sha256.toUpperCase() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-04-amap-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-EXT-003-real-amap");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(caseDirectory, { recursive: true });
|
||||||
|
|
||||||
|
function run(command, args, evidenceCommand = [command, ...args].join(" ")) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command;
|
||||||
|
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args;
|
||||||
|
const result = spawnSync(executable, actualArgs, { encoding: "utf8" });
|
||||||
|
return {
|
||||||
|
command: evidenceCommand,
|
||||||
|
exit_code: result.status ?? 1,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
stderr: result.stderr ?? "",
|
||||||
|
stdout: result.stdout ?? "",
|
||||||
|
started_at: startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipped(command) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return { command, exit_code: 1, finished_at: timestamp, started_at: timestamp, stderr: "prerequisite_failed", stdout: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConsoleReview() {
|
||||||
|
const raw = process.env.DADA_AMAP_CONSOLE_REVIEW_JSON;
|
||||||
|
const fallback = {
|
||||||
|
allowlist: "not_verified",
|
||||||
|
auto_scaling: "not_verified",
|
||||||
|
paid_fallback: "not_verified",
|
||||||
|
qps: "not_verified",
|
||||||
|
qps_limit_per_second: null,
|
||||||
|
reviewed_at: null,
|
||||||
|
security_restriction: "not_verified",
|
||||||
|
service_binding: "not_verified",
|
||||||
|
source: "not_supplied",
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const requiredKeys = ["allowlist", "auto_scaling", "paid_fallback", "qps", "qps_limit_per_second", "reviewed_at", "security_restriction", "service_binding", "source"];
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).sort().join("|") !== requiredKeys.sort().join("|")) {
|
||||||
|
return { ...fallback, source: "invalid_input", valid: false };
|
||||||
|
}
|
||||||
|
const statusFields = ["allowlist", "qps", "security_restriction", "service_binding"];
|
||||||
|
const statusValid = statusFields.every((field) => ["failed", "not_verified", "passed"].includes(parsed[field]));
|
||||||
|
const disabledFieldsValid = ["auto_scaling", "paid_fallback"].every((field) => ["disabled", "not_verified"].includes(parsed[field]));
|
||||||
|
const qpsLimitValid = parsed.qps === "passed"
|
||||||
|
? Number.isSafeInteger(parsed.qps_limit_per_second) && parsed.qps_limit_per_second >= 1 && parsed.qps_limit_per_second <= 1_000
|
||||||
|
: parsed.qps_limit_per_second === null;
|
||||||
|
const reviewedAtValid = typeof parsed.reviewed_at === "string" && Number.isFinite(Date.parse(parsed.reviewed_at));
|
||||||
|
if (!statusValid || !disabledFieldsValid || !qpsLimitValid || !reviewedAtValid || parsed.source !== "amap_console_manual_review") {
|
||||||
|
return { ...fallback, source: "invalid_input", valid: false };
|
||||||
|
}
|
||||||
|
return { ...parsed, valid: true };
|
||||||
|
} catch {
|
||||||
|
return { ...fallback, source: "invalid_input", valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(path, value) {
|
||||||
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeCode(value) {
|
||||||
|
return typeof value === "string" && /^[a-z0-9_]{1,64}$/.test(value) ? value : "invalid_output";
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsForbiddenEvidence(value) {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
return [
|
||||||
|
/[A-Za-z]:[\\/](?:Users|Documents)[\\/]/i,
|
||||||
|
/"(?:api[_-]?key|credential|secret_value|latitude|longitude|coordinates|email_address|ip_address)"\s*:/i,
|
||||||
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||||
|
].some((pattern) => pattern.test(serialized));
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleReview = readConsoleReview();
|
||||||
|
const build = run("pnpm", ["build:workspace-packages"]);
|
||||||
|
const locationRegression = build.exit_code === 0
|
||||||
|
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts"])
|
||||||
|
: skipped("pnpm exec vitest run tests/api/wp4-04-location.test.ts");
|
||||||
|
const serviceRegression = build.exit_code === 0
|
||||||
|
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp6-03-services.test.ts"])
|
||||||
|
: skipped("pnpm exec vitest run tests/api/wp6-03-services.test.ts");
|
||||||
|
const localHardStop = build.exit_code === 0
|
||||||
|
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-release-gate.test.ts"])
|
||||||
|
: skipped("pnpm exec vitest run tests/api/wp7-04-amap-release-gate.test.ts");
|
||||||
|
const productionAdapter = build.exit_code === 0
|
||||||
|
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-production-adapter.test.ts"])
|
||||||
|
: skipped("pnpm exec vitest run tests/api/wp7-04-amap-production-adapter.test.ts");
|
||||||
|
const consentFlow = build.exit_code === 0
|
||||||
|
? run("pnpm", ["exec", "vitest", "run", "tests/unit/wp4-04-palette-dynamic.test.ts"])
|
||||||
|
: skipped("pnpm exec vitest run tests/unit/wp4-04-palette-dynamic.test.ts");
|
||||||
|
const supervisorBuild = run("dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configuration", "Release"]);
|
||||||
|
const supervisorSecurity = supervisorBuild.exit_code === 0
|
||||||
|
? run("dotnet", ["run", "--project", "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", "--configuration", "Release"])
|
||||||
|
: skipped("dotnet run --project supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj --configuration Release");
|
||||||
|
const supervisorExe = resolve("supervisor", "Dada.Supervisor", "bin", "Release", "net8.0-windows", "Dada.Supervisor.exe");
|
||||||
|
const external = supervisorBuild.exit_code === 0 && existsSync(supervisorExe)
|
||||||
|
? run(supervisorExe, ["secrets", "probe", "api-amap"], "Dada.Supervisor.exe secrets probe api-amap")
|
||||||
|
: skipped("Dada.Supervisor.exe secrets probe api-amap");
|
||||||
|
const trace = run("pnpm", ["validate:tdd-trace"]);
|
||||||
|
const security = run("pnpm", ["test:security"]);
|
||||||
|
const commands = [build, locationRegression, serviceRegression, localHardStop, productionAdapter, consentFlow, supervisorBuild, supervisorSecurity, external, trace, security]
|
||||||
|
.map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at }));
|
||||||
|
const parsedExternal = (() => {
|
||||||
|
try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return {}; }
|
||||||
|
})();
|
||||||
|
const safeProbeCode = probeCode(parsedExternal.code);
|
||||||
|
const realCalls = Number.isSafeInteger(parsedExternal.real_calls) && parsedExternal.real_calls >= 0 && parsedExternal.real_calls <= 2
|
||||||
|
? parsedExternal.real_calls
|
||||||
|
: 0;
|
||||||
|
const probePassed = external.exit_code === 0 && safeProbeCode === "amap_probe_passed" && realCalls === 2;
|
||||||
|
const hardStopPassed = localHardStop.exit_code === 0;
|
||||||
|
const productionAdapterPassed = productionAdapter.exit_code === 0;
|
||||||
|
const consentPassed = consentFlow.exit_code === 0;
|
||||||
|
const supervisorSecurityPassed = supervisorSecurity.exit_code === 0;
|
||||||
|
const regressionPassed = locationRegression.exit_code === 0 && serviceRegression.exit_code === 0;
|
||||||
|
const automatedPassed = build.exit_code === 0 && hardStopPassed && productionAdapterPassed && consentPassed && supervisorSecurityPassed && regressionPassed && trace.exit_code === 0 && security.exit_code === 0;
|
||||||
|
const manualReviewPassed = consoleReview.valid
|
||||||
|
&& consoleReview.service_binding === "passed"
|
||||||
|
&& consoleReview.qps === "passed"
|
||||||
|
&& consoleReview.allowlist === "passed"
|
||||||
|
&& consoleReview.security_restriction === "passed"
|
||||||
|
&& consoleReview.paid_fallback === "disabled"
|
||||||
|
&& consoleReview.auto_scaling === "disabled";
|
||||||
|
|
||||||
|
const requiredBeforeRelease = [];
|
||||||
|
if (!probePassed) requiredBeforeRelease.push("real_location_and_reverse_geocode");
|
||||||
|
if (consoleReview.service_binding !== "passed") requiredBeforeRelease.push("service_binding");
|
||||||
|
if (consoleReview.qps !== "passed") requiredBeforeRelease.push("qps_confirmation");
|
||||||
|
if (consoleReview.allowlist !== "passed") requiredBeforeRelease.push("allowlist_confirmation");
|
||||||
|
if (consoleReview.security_restriction !== "passed") requiredBeforeRelease.push("security_restriction");
|
||||||
|
if (consoleReview.paid_fallback !== "disabled") requiredBeforeRelease.push("paid_fallback_disabled");
|
||||||
|
if (consoleReview.auto_scaling !== "disabled") requiredBeforeRelease.push("auto_scaling_disabled");
|
||||||
|
if (!hardStopPassed) requiredBeforeRelease.push("monthly_1000_hard_stop");
|
||||||
|
if (!productionAdapterPassed) requiredBeforeRelease.push("production_amap_adapter");
|
||||||
|
if (!consentPassed) requiredBeforeRelease.push("dyn004_confirmation");
|
||||||
|
if (!supervisorSecurityPassed) requiredBeforeRelease.push("controlled_probe_security");
|
||||||
|
|
||||||
|
const blocker = !automatedPassed
|
||||||
|
? "automated_regression_failed"
|
||||||
|
: !probePassed
|
||||||
|
? safeProbeCode
|
||||||
|
: !consoleReview.valid
|
||||||
|
? "manual_review_invalid"
|
||||||
|
: consoleReview.allowlist === "failed"
|
||||||
|
? "amap_allowlist_unconfigured"
|
||||||
|
: consoleReview.security_restriction === "failed"
|
||||||
|
? "amap_security_restriction_unconfigured"
|
||||||
|
: !manualReviewPassed
|
||||||
|
? "manual_acceptance_required"
|
||||||
|
: null;
|
||||||
|
const status = !automatedPassed ? "failed" : probePassed && manualReviewPassed ? "passed" : "externally_blocked";
|
||||||
|
|
||||||
|
const contractEvidence = {
|
||||||
|
blocker,
|
||||||
|
checks: {
|
||||||
|
allowlist: consoleReview.allowlist === "passed" ? "manual_console_passed" : consoleReview.allowlist,
|
||||||
|
auto_scaling: consoleReview.auto_scaling,
|
||||||
|
client_security_controls: productionAdapterPassed && supervisorSecurityPassed ? "automated_passed" : "failed",
|
||||||
|
controlled_probe_security: supervisorSecurityPassed ? "automated_passed" : "failed",
|
||||||
|
dyn004_hard_stop: consentPassed ? "confirmation_contract_passed" : "failed",
|
||||||
|
location_and_reverse_geocode: probePassed ? "real_probe_passed" : "not_verified",
|
||||||
|
monthly_hard_limit_1000: hardStopPassed ? "local_pre_egress_passed" : "failed",
|
||||||
|
paid_fallback: consoleReview.paid_fallback,
|
||||||
|
production_adapter: productionAdapterPassed ? "credential_channel_real_adapter_passed" : "failed",
|
||||||
|
provider_qps: consoleReview.qps === "passed" ? "manual_console_passed" : consoleReview.qps,
|
||||||
|
provider_qps_limit_per_second: consoleReview.qps_limit_per_second,
|
||||||
|
security_binding: consoleReview.security_restriction === "passed" ? "manual_console_passed" : consoleReview.security_restriction,
|
||||||
|
service_binding: consoleReview.service_binding === "passed" ? "manual_console_passed" : consoleReview.service_binding,
|
||||||
|
},
|
||||||
|
mode: probePassed ? "controlled_real" : "blocked",
|
||||||
|
real_calls: realCalls,
|
||||||
|
status,
|
||||||
|
schema_version: "1.1",
|
||||||
|
};
|
||||||
|
const externalCallsEvidence = {
|
||||||
|
blocker,
|
||||||
|
mode: probePassed ? "controlled_real" : "blocked",
|
||||||
|
real_calls: realCalls,
|
||||||
|
request_scope: ["geocoding", "reverse_geocoding"],
|
||||||
|
service: "amap",
|
||||||
|
source_command_status: safeProbeCode,
|
||||||
|
status,
|
||||||
|
schema_version: "1.1",
|
||||||
|
};
|
||||||
|
const manualReviewEvidence = {
|
||||||
|
blocker,
|
||||||
|
checks: {
|
||||||
|
allowlist: consoleReview.allowlist,
|
||||||
|
auto_scaling: consoleReview.auto_scaling,
|
||||||
|
paid_fallback: consoleReview.paid_fallback,
|
||||||
|
qps: consoleReview.qps,
|
||||||
|
qps_limit_per_second: consoleReview.qps_limit_per_second,
|
||||||
|
security_restriction: consoleReview.security_restriction,
|
||||||
|
service_binding: consoleReview.service_binding,
|
||||||
|
},
|
||||||
|
required_before_release: requiredBeforeRelease,
|
||||||
|
reviewed_at: consoleReview.reviewed_at,
|
||||||
|
source: consoleReview.source,
|
||||||
|
status,
|
||||||
|
schema_version: "1.1",
|
||||||
|
};
|
||||||
|
const result = {
|
||||||
|
acceptance_criteria: ["AC-17", "AC-41", "AC-47"],
|
||||||
|
automation: ["controlled_real", "manual_review"],
|
||||||
|
blocker,
|
||||||
|
contract_regression: automatedPassed ? "passed" : "failed",
|
||||||
|
external_calls: realCalls,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() },
|
||||||
|
missing_evidence: requiredBeforeRelease,
|
||||||
|
parent_family: "TDD-WP7-EXT-003",
|
||||||
|
phase: "green",
|
||||||
|
release_gate: ["release:P0-A"],
|
||||||
|
requirements: ["DYN-04", "PRIV-03"],
|
||||||
|
run_id: runId,
|
||||||
|
schema_version: "1.1",
|
||||||
|
status,
|
||||||
|
task_id: "TASK-WP7-04",
|
||||||
|
test_id: "TDD-WP7-EXT-003-real-amap",
|
||||||
|
work_package: "WP-7",
|
||||||
|
};
|
||||||
|
const commandsEvidence = { commands, run_id: runId, schema_version: "1.1" };
|
||||||
|
const evidenceIndex = { cases: [{ status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.1" };
|
||||||
|
const redactionInputs = [contractEvidence, externalCallsEvidence, manualReviewEvidence, result, commandsEvidence, evidenceIndex];
|
||||||
|
const evidenceRedactionPassed = !redactionInputs.some(containsForbiddenEvidence);
|
||||||
|
const redactionEvidence = {
|
||||||
|
findings: evidenceRedactionPassed ? [] : ["forbidden_evidence_field"],
|
||||||
|
forbidden_fields_present: !evidenceRedactionPassed,
|
||||||
|
source_security_scan: security.exit_code === 0 ? "passed" : "failed",
|
||||||
|
status: evidenceRedactionPassed && security.exit_code === 0 ? "passed" : "failed",
|
||||||
|
stored_fields: ["service", "status", "logical_limit", "manual_review_status", "qps_limit_per_second"],
|
||||||
|
schema_version: "1.1",
|
||||||
|
};
|
||||||
|
if (redactionEvidence.status !== "passed") {
|
||||||
|
result.status = "failed";
|
||||||
|
result.blocker = "redaction_failed";
|
||||||
|
evidenceIndex.status = "failed";
|
||||||
|
evidenceIndex.cases[0].status = "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(resolve(caseDirectory, "amap-contract.json"), contractEvidence);
|
||||||
|
writeJson(resolve(caseDirectory, "external-calls.json"), externalCallsEvidence);
|
||||||
|
writeJson(resolve(caseDirectory, "redaction.json"), redactionEvidence);
|
||||||
|
writeJson(resolve(caseDirectory, "manual-review.json"), manualReviewEvidence);
|
||||||
|
writeJson(resolve(caseDirectory, "commands.json"), commandsEvidence);
|
||||||
|
writeJson(resolve(caseDirectory, "result.json"), result);
|
||||||
|
writeJson(resolve(runDirectory, "evidence.json"), evidenceIndex);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
if (result.status === "failed") process.exit(1);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { runWp705Gate } from './lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const result = runWp705Gate({
|
||||||
|
candidatePath: process.env.WP7_01_CANDIDATE_RECORD,
|
||||||
|
evidence: null,
|
||||||
|
dependencies: {
|
||||||
|
'TASK-WP7-03': 'externally_blocked',
|
||||||
|
'TASK-WP7-04': 'externally_blocked',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ task: 'TASK-WP7-05', ...result }));
|
||||||
|
process.exitCode = result.status === 'ready_for_execution' ? 0 : 3;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { validateTddTrace } from "./lib/tdd-trace.mjs";
|
||||||
|
import { buildWp706PrefreezeReport, REQUIRED_UPSTREAM } from "./lib/wp7-06-prefreeze.mjs";
|
||||||
|
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-06-prefreeze-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-AC-001-trace-structure");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(caseDirectory, { recursive: true });
|
||||||
|
|
||||||
|
function writeJson(path, value) {
|
||||||
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args) {
|
||||||
|
const result = spawnSync("git", args, { encoding: "utf8", timeout: 60_000 });
|
||||||
|
if ((result.status ?? 1) !== 0) throw new Error(`WP7_06_GIT_COMMAND_FAILED:${args[0]}`);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(name, command) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: process.env,
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
exit_code: result.status ?? 1,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
name,
|
||||||
|
started_at: startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
runCommand("prefreeze-unit", "node --test tests/package/wp7-06-prefreeze.test.mjs"),
|
||||||
|
runCommand("tdd-trace", "pnpm validate:tdd-trace"),
|
||||||
|
];
|
||||||
|
if (commands.some((command) => command.exit_code !== 0)) {
|
||||||
|
writeJson(resolve(caseDirectory, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trace = validateTddTrace();
|
||||||
|
const currentCommit = git(["rev-parse", "HEAD"]);
|
||||||
|
const remoteLines = git(["ls-remote", "--heads", "origin", "codex/wp7-01", "codex/wp7-02", "codex/wp7-03", "codex/wp7-04", "codex/wp7-05"])
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const remoteHeads = Object.fromEntries(remoteLines.map((line) => {
|
||||||
|
const [head, reference] = line.split(/\s+/);
|
||||||
|
return [reference.replace("refs/heads/", ""), head];
|
||||||
|
}));
|
||||||
|
const upstream = Object.fromEntries(Object.entries(REQUIRED_UPSTREAM).map(([taskId, status]) => {
|
||||||
|
const branch = taskId.replace("TASK-", "codex/").toLowerCase();
|
||||||
|
const head = remoteHeads[branch];
|
||||||
|
const ancestry = spawnSync("git", ["merge-base", "--is-ancestor", head ?? "missing", "HEAD"], { encoding: "utf8", timeout: 30_000 });
|
||||||
|
return [taskId, { branch, head, merged: ancestry.status === 0, status }];
|
||||||
|
}));
|
||||||
|
const report = buildWp706PrefreezeReport({
|
||||||
|
currentCommit,
|
||||||
|
releaseExists: existsSync(resolve("RELEASE.json")),
|
||||||
|
trace,
|
||||||
|
upstream,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
acceptance_criteria: Array.from({ length: 56 }, (_, index) => index + 1)
|
||||||
|
.filter((number) => ![8, 26, 37, 54].includes(number))
|
||||||
|
.map((number) => `AC-${String(number).padStart(2, "0")}`),
|
||||||
|
automation: ["automated", "manual_review"],
|
||||||
|
commit: currentCommit,
|
||||||
|
evidence_refs: ["commands.json", "trace-summary.json", "upstream-lineage.json", "deferred-external.json"],
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
manifest: {
|
||||||
|
path: "tasks.manifest.json",
|
||||||
|
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
|
||||||
|
},
|
||||||
|
missing_evidence: [],
|
||||||
|
release_gate: ["release:P0-A"],
|
||||||
|
requirements: ["NFR-05"],
|
||||||
|
run_id: runId,
|
||||||
|
schema_version: "1.0",
|
||||||
|
status: report.status,
|
||||||
|
task_id: "TASK-WP7-06",
|
||||||
|
test_id: "TDD-WP7-AC-001-trace-structure",
|
||||||
|
work_package: "WP-7",
|
||||||
|
};
|
||||||
|
|
||||||
|
writeJson(resolve(caseDirectory, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
|
||||||
|
writeJson(resolve(caseDirectory, "trace-summary.json"), { status: trace.status, summary: trace.summary, schema_version: "1.0" });
|
||||||
|
writeJson(resolve(caseDirectory, "upstream-lineage.json"), { current_commit: currentCommit, tasks: report.upstream, schema_version: "1.0" });
|
||||||
|
writeJson(resolve(caseDirectory, "deferred-external.json"), {
|
||||||
|
policy: "product_owner_first_version_nonblocking",
|
||||||
|
tasks: report.deferred_external_tasks,
|
||||||
|
treated_as_real_provider_pass: false,
|
||||||
|
schema_version: "1.0",
|
||||||
|
});
|
||||||
|
writeJson(resolve(caseDirectory, "result.json"), result);
|
||||||
|
writeJson(resolve(runDirectory, "evidence.json"), { cases: [{ missing_evidence: [], status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.0" });
|
||||||
|
console.log(JSON.stringify({ deferred_external_tasks: report.deferred_external_tasks, next_task: report.next_task, run_id: runId, status: report.status }, null, 2));
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
||||||
|
import { readCandidateEnvironment } from "./lib/release-candidate.mjs";
|
||||||
|
import {
|
||||||
|
buildFinalReleaseRecord,
|
||||||
|
scanReleaseFiles,
|
||||||
|
sha256File,
|
||||||
|
validateFinalEvidence,
|
||||||
|
} from "./lib/wp7-07-final-release.mjs";
|
||||||
|
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-07-final-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const releaseCase = resolve(runDirectory, "cases", "TDD-WP7-REL-001-final-release-record");
|
||||||
|
const securityCase = resolve(runDirectory, "cases", "TDD-WP7-SEC-001-artifact-leak-scan");
|
||||||
|
const outputRoot = resolve(".build", "wp7-07-final-release");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(releaseCase, { recursive: true });
|
||||||
|
mkdirSync(securityCase, { recursive: true });
|
||||||
|
|
||||||
|
function writeJson(path, value) {
|
||||||
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args) {
|
||||||
|
const result = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
|
||||||
|
if ((result.status ?? 1) !== 0) throw new Error(`WP7_07_GIT_FAILED:${args.join(" ")}`);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitGrep(args) {
|
||||||
|
const result = spawnSync("git", ["grep", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
|
||||||
|
if (![0, 1].includes(result.status ?? 2)) throw new Error("WP7_07_GIT_GREP_FAILED");
|
||||||
|
return result.status === 0 ? result.stdout.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(name, command) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: process.env,
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
return { command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at: startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCommit = git(["rev-parse", "HEAD"]);
|
||||||
|
const prefreezeCommit = git(["ls-remote", "origin", "refs/heads/codex/wp7-06"]).split(/\s+/)[0];
|
||||||
|
if (!prefreezeCommit || spawnSync("git", ["merge-base", "--is-ancestor", prefreezeCommit, "HEAD"]).status !== 0) {
|
||||||
|
throw new Error("WP7_07_PREFREEZE_LINEAGE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
run("unit", "node --test tests/package/wp7-07-final-release.test.mjs"),
|
||||||
|
run("security", "pnpm test:security"),
|
||||||
|
run("trace", "pnpm validate:tdd-trace"),
|
||||||
|
];
|
||||||
|
if (commands.some(({ exit_code }) => exit_code !== 0)) {
|
||||||
|
writeJson(join(releaseCase, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = readCandidateEnvironment();
|
||||||
|
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
|
||||||
|
const release = buildFinalReleaseRecord({
|
||||||
|
appVersion: packageJson.version,
|
||||||
|
browsers: environment.browsers.map(({ brand, full_version }) => ({ brand, fullVersion: full_version })),
|
||||||
|
buildCommit: currentCommit,
|
||||||
|
frozenFromCommit: prefreezeCommit,
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
windows: {
|
||||||
|
arch: environment.windows.arch,
|
||||||
|
build: environment.windows.build,
|
||||||
|
displayVersion: environment.windows.display_version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeJson(resolve("RELEASE.json"), release);
|
||||||
|
|
||||||
|
const packageResult = await buildAndValidatePortablePackage({ evidenceDirectory: releaseCase, outputRoot, releaseRecord: release });
|
||||||
|
const packageDirectory = join(outputRoot, packageResult.packageManifest.package_name);
|
||||||
|
const zipPath = join(outputRoot, `${packageResult.packageManifest.package_name}.zip`);
|
||||||
|
const releaseSha256 = sha256File(resolve("RELEASE.json"));
|
||||||
|
const packageReleaseSha256 = sha256File(join(packageDirectory, "RELEASE.json"));
|
||||||
|
if (releaseSha256 !== packageReleaseSha256) throw new Error("WP7_07_PACKAGE_RELEASE_DRIFT");
|
||||||
|
|
||||||
|
const finalScan = scanReleaseFiles({ roots: [packageDirectory, releaseCase] });
|
||||||
|
const trackedSensitive = gitGrep(["-I", "-n", "-E", "C:\\\\Users\\\\[^\\\\]+|sk-[A-Za-z0-9_-]{24,}", "HEAD", "--", ":!tests", ":!scripts/lib/wp7-07-final-release.mjs"]);
|
||||||
|
const scan = {
|
||||||
|
...finalScan,
|
||||||
|
git_current_findings: trackedSensitive ? trackedSensitive.split(/\r?\n/).filter(Boolean) : [],
|
||||||
|
status: finalScan.status === "passed" && !trackedSensitive ? "passed" : "failed",
|
||||||
|
};
|
||||||
|
writeJson(join(securityCase, "scan-report.json"), scan);
|
||||||
|
writeJson(join(securityCase, "allowlist.json"), {
|
||||||
|
entries: ["tests/**:synthetic security traps", "scripts/lib/wp7-07-final-release.mjs:scanner patterns"],
|
||||||
|
real_values_allowed: false,
|
||||||
|
schema_version: "1.0",
|
||||||
|
});
|
||||||
|
if (scan.status !== "passed") throw new Error("WP7_07_LEAK_SCAN_FAILED");
|
||||||
|
|
||||||
|
const finalEvidence = validateFinalEvidence({ packageManifest: packageResult.packageManifest, release, releaseSha256, scan });
|
||||||
|
copyFileSync(resolve("RELEASE.json"), join(releaseCase, "RELEASE.json"));
|
||||||
|
copyFileSync(join(packageDirectory, "START-HERE.txt"), join(releaseCase, "START-HERE.txt"));
|
||||||
|
writeJson(join(releaseCase, "environment.json"), {
|
||||||
|
browsers: release.browsers,
|
||||||
|
fixed_port: release.fixedPort,
|
||||||
|
windows: release.windows,
|
||||||
|
schema_version: "1.0",
|
||||||
|
});
|
||||||
|
writeJson(join(releaseCase, "final-package.json"), {
|
||||||
|
file_name: basename(zipPath),
|
||||||
|
release_sha256: finalEvidence.release_sha256,
|
||||||
|
release_status: release.releaseStatus,
|
||||||
|
zip_sha256: finalEvidence.zip_sha256,
|
||||||
|
schema_version: "1.0",
|
||||||
|
});
|
||||||
|
writeJson(join(releaseCase, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
|
||||||
|
writeJson(join(releaseCase, "result.json"), {
|
||||||
|
acceptance_criteria: ["AC-24", "AC-41", "AC-48", "AC-56"],
|
||||||
|
deferred_external_tasks: release.deferredExternalTasks,
|
||||||
|
evidence_refs: ["RELEASE.json", "START-HERE.txt", "environment.json", "package-manifest.json", "final-package.json"],
|
||||||
|
release_gate: ["release:P0-A"],
|
||||||
|
requirements: ["NFR-01", "NFR-09", "PRIV-01", "PRIV-02"],
|
||||||
|
status: "passed",
|
||||||
|
task_id: "TASK-WP7-07",
|
||||||
|
test_id: "TDD-WP7-REL-001-final-release-record",
|
||||||
|
});
|
||||||
|
writeJson(join(securityCase, "result.json"), {
|
||||||
|
evidence_refs: ["scan-report.json", "allowlist.json"],
|
||||||
|
status: "passed",
|
||||||
|
task_id: "TASK-WP7-07",
|
||||||
|
test_id: "TDD-WP7-SEC-001-artifact-leak-scan",
|
||||||
|
});
|
||||||
|
writeJson(join(runDirectory, "evidence.json"), {
|
||||||
|
cases: [
|
||||||
|
{ missing_evidence: [], status: "passed", test_id: "TDD-WP7-REL-001-final-release-record" },
|
||||||
|
{ missing_evidence: [], status: "passed", test_id: "TDD-WP7-SEC-001-artifact-leak-scan" },
|
||||||
|
],
|
||||||
|
deferred_external_tasks: release.deferredExternalTasks,
|
||||||
|
release_sha256: finalEvidence.release_sha256,
|
||||||
|
run_id: runId,
|
||||||
|
status: "passed",
|
||||||
|
zip_sha256: finalEvidence.zip_sha256,
|
||||||
|
schema_version: "1.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
deferred_external_tasks: release.deferredExternalTasks,
|
||||||
|
release_sha256: finalEvidence.release_sha256,
|
||||||
|
run_id: runId,
|
||||||
|
status: "passed",
|
||||||
|
zip_sha256: finalEvidence.zip_sha256,
|
||||||
|
}, null, 2));
|
||||||
@@ -38,7 +38,9 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
var security = await TestCredentialBoundaryAsync();
|
var security = await TestCredentialBoundaryAsync();
|
||||||
var supervisor = await TestSupervisorLifecycleAsync();
|
var supervisor = await TestSupervisorLifecycleAsync();
|
||||||
|
await TestAmapProbeSecurityAsync();
|
||||||
TestSecureConfigurationPersistence();
|
TestSecureConfigurationPersistence();
|
||||||
|
TestRuntimeDirectoryBootstrap();
|
||||||
TestStructuredLogging();
|
TestStructuredLogging();
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
||||||
@@ -52,6 +54,20 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task TestAmapProbeSecurityAsync()
|
||||||
|
{
|
||||||
|
using var handler = AmapProbe.CreateHandler();
|
||||||
|
False(handler.AllowAutoRedirect, "Amap probe redirects disabled");
|
||||||
|
Equal(1, handler.MaxConnectionsPerServer, "Amap probe per-server connection cap");
|
||||||
|
True(AmapProbe.IsAllowedEndpoint(new Uri("https://restapi.amap.com/v3/geocode/regeo")), "Amap fixed HTTPS endpoint accepted");
|
||||||
|
False(AmapProbe.IsAllowedEndpoint(new Uri("http://restapi.amap.com/v3/geocode/regeo")), "Amap HTTP endpoint rejected");
|
||||||
|
False(AmapProbe.IsAllowedEndpoint(new Uri("https://example.invalid/v3/geocode/regeo")), "Amap alternate host rejected");
|
||||||
|
using var oversized = new ByteArrayContent(new byte[AmapProbe.MaximumResponseBytes + 1]);
|
||||||
|
await ThrowsAsync<InvalidDataException>(
|
||||||
|
() => AmapProbe.ReadBoundedJsonAsync(oversized),
|
||||||
|
"Amap oversized response must be rejected before parsing");
|
||||||
|
}
|
||||||
|
|
||||||
private static void TestStructuredLogging()
|
private static void TestStructuredLogging()
|
||||||
{
|
{
|
||||||
Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit");
|
Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit");
|
||||||
@@ -110,6 +126,29 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TestRuntimeDirectoryBootstrap()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"dada-runtime-root-{Guid.NewGuid():N}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
SupervisorRuntime.EnsureRuntimeDirectories(root);
|
||||||
|
foreach (var relativePath in new[]
|
||||||
|
{
|
||||||
|
"db", "content/references", "content/generated", "content/exports",
|
||||||
|
"managed-assets", "derived-assets", "staging",
|
||||||
|
"logs/api", "logs/worker", "logs/supervisor",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
True(Directory.Exists(Path.Combine(root, relativePath)), $"runtime directory missing: {relativePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object> TestCredentialBoundaryAsync()
|
private static async Task<object> TestCredentialBoundaryAsync()
|
||||||
{
|
{
|
||||||
var store = new TestCredentialStore();
|
var store = new TestCredentialStore();
|
||||||
@@ -131,6 +170,8 @@ internal static class Program
|
|||||||
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
|
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
|
||||||
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
|
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
|
||||||
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
|
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
|
||||||
|
True(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"mime_type\":\"image/png\",\"pixel_height\":1080,\"pixel_width\":1080,\"real_calls\":1,\"success\":true}", out _), "AI probe success output accepted");
|
||||||
|
False(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"raw_body\":\"private\",\"real_calls\":1,\"success\":true}", out _), "AI probe private output rejected");
|
||||||
|
|
||||||
var externalArguments = new[]
|
var externalArguments = new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
|
internal static class AiGatewayProbe
|
||||||
|
{
|
||||||
|
internal static async Task<int> RunAsync(ICredentialStore credentials, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||||
|
var worker = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||||
|
if (!File.Exists(node) || !File.Exists(worker)) return WriteFailure("ai_probe_runtime_missing", 0);
|
||||||
|
var startInfo = new ProcessStartInfo(node) { WorkingDirectory = AppContext.BaseDirectory };
|
||||||
|
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||||
|
startInfo.ArgumentList.Add(worker);
|
||||||
|
startInfo.ArgumentList.Add("--dada-ai-probe");
|
||||||
|
startInfo.ArgumentList.Add("--dada-credential-stdin");
|
||||||
|
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
|
||||||
|
if (result.SensitiveOutputDetected || result.StandardError.Length > 0 || !TryValidateOutput(result.StandardOutput, out var sanitized))
|
||||||
|
{
|
||||||
|
return WriteFailure("ai_probe_runtime_failed", 0);
|
||||||
|
}
|
||||||
|
Console.WriteLine(sanitized);
|
||||||
|
return result.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryValidateOutput(string output, out string sanitized)
|
||||||
|
{
|
||||||
|
sanitized = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(output);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
var allowed = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"code", "error_category", "mime_type", "pixel_height", "pixel_width", "real_calls", "success",
|
||||||
|
};
|
||||||
|
if (root.EnumerateObject().Any(property => !allowed.Contains(property.Name))) return false;
|
||||||
|
if (!root.TryGetProperty("success", out var success) || success.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
|
||||||
|
if (!root.TryGetProperty("real_calls", out var realCalls) || realCalls.ValueKind != JsonValueKind.Number || !realCalls.TryGetInt32(out var count) || count is < 0 or > 1) return false;
|
||||||
|
var passed = success.GetBoolean();
|
||||||
|
var code = root.GetProperty("code").GetString();
|
||||||
|
if (passed)
|
||||||
|
{
|
||||||
|
if (code != "ai_probe_passed" || count != 1) return false;
|
||||||
|
var mime = root.GetProperty("mime_type").GetString();
|
||||||
|
if (mime is not ("image/jpeg" or "image/png" or "image/webp")) return false;
|
||||||
|
if (!PositiveDimension(root, "pixel_width") || !PositiveDimension(root, "pixel_height")) return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (code != "ai_probe_failed" || !root.TryGetProperty("error_category", out var category)
|
||||||
|
|| category.ValueKind != JsonValueKind.String || (category.GetString()?.Length ?? 0) is < 1 or > 64) return false;
|
||||||
|
}
|
||||||
|
sanitized = JsonSerializer.Serialize(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PositiveDimension(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||||
|
&& value.TryGetInt32(out var dimension) && dimension is > 0 and <= 4096;
|
||||||
|
|
||||||
|
private static int WriteFailure(string code, int realCalls)
|
||||||
|
{
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(new { code, real_calls = realCalls, success = false }));
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
|
internal static class AmapProbe
|
||||||
|
{
|
||||||
|
internal const int MaximumResponseBytes = 65_536;
|
||||||
|
private const string ProviderHostname = "restapi.amap.com";
|
||||||
|
private static readonly HttpClient Client = new(CreateHandler()) { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
|
||||||
|
internal static SocketsHttpHandler CreateHandler() => new()
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
AutomaticDecompression = DecompressionMethods.None,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
MaxConnectionsPerServer = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static bool IsAllowedEndpoint(Uri endpoint) =>
|
||||||
|
endpoint.Scheme == Uri.UriSchemeHttps
|
||||||
|
&& endpoint.Host.Equals(ProviderHostname, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (endpoint.IsDefaultPort || endpoint.Port == 443)
|
||||||
|
&& string.IsNullOrEmpty(endpoint.UserInfo)
|
||||||
|
&& endpoint.AbsolutePath is "/v3/geocode/regeo" or "/v3/geocode/geo";
|
||||||
|
|
||||||
|
internal static int Run(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
Write("amap_credentials_missing", false, 0, null, null);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
var realCalls = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
realCalls += 1;
|
||||||
|
var reverse = Call("https://restapi.amap.com/v3/geocode/regeo?location=120.6994,27.9943&extensions=base", key).GetAwaiter().GetResult();
|
||||||
|
realCalls += 1;
|
||||||
|
var geocode = Call($"https://restapi.amap.com/v3/geocode/geo?address={Uri.EscapeDataString("北京市天安门")}", key).GetAwaiter().GetResult();
|
||||||
|
var success = reverse.Status == "1" && geocode.Status == "1";
|
||||||
|
Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, realCalls, reverse.Status, geocode.Status);
|
||||||
|
return success ? 0 : 3;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
Write("amap_probe_timeout", false, realCalls, null, null);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException exception)
|
||||||
|
{
|
||||||
|
Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, realCalls, null, null);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||||
|
{
|
||||||
|
Write("amap_probe_invalid_response", false, realCalls, null, null);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ProbeResponse> Call(string endpoint, string key)
|
||||||
|
{
|
||||||
|
var requestUri = new Uri($"{endpoint}&key={Uri.EscapeDataString(key)}", UriKind.Absolute);
|
||||||
|
if (!IsAllowedEndpoint(requestUri)) throw new InvalidDataException("amap_endpoint_rejected");
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using var document = await ReadBoundedJsonAsync(response.Content);
|
||||||
|
var root = document.RootElement;
|
||||||
|
return new ProbeResponse(root.GetProperty("status").GetString() ?? "", root.TryGetProperty("infocode", out var code) ? code.GetString() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<JsonDocument> ReadBoundedJsonAsync(HttpContent content)
|
||||||
|
{
|
||||||
|
if (content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large");
|
||||||
|
await using var stream = await content.ReadAsStreamAsync();
|
||||||
|
using var buffered = new MemoryStream();
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(4_096);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await stream.ReadAsync(buffer);
|
||||||
|
if (read == 0) break;
|
||||||
|
total += read;
|
||||||
|
if (total > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large");
|
||||||
|
await buffered.WriteAsync(buffer.AsMemory(0, read));
|
||||||
|
}
|
||||||
|
buffered.Position = 0;
|
||||||
|
return await JsonDocument.ParseAsync(buffered);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Array.Clear(buffer);
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Write(string code, bool success, int realCalls, string? reverseStatus, string? geocodeStatus) =>
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(new { code, success, real_calls = realCalls, reverse_status = reverseStatus, geocode_status = geocodeStatus }));
|
||||||
|
|
||||||
|
private sealed record ProbeResponse(string Status, string? InfoCode);
|
||||||
|
}
|
||||||
@@ -50,7 +50,9 @@ internal static class CredentialProcessLauncher
|
|||||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||||
{
|
{
|
||||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
var value = store.Read(target);
|
||||||
|
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
|
||||||
|
credentials[target] = value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
startInfo.UseShellExecute = false;
|
startInfo.UseShellExecute = false;
|
||||||
@@ -98,7 +100,9 @@ internal static class CredentialProcessLauncher
|
|||||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||||
{
|
{
|
||||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
var value = store.Read(target);
|
||||||
|
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
|
||||||
|
credentials[target] = value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
startInfo.UseShellExecute = false;
|
startInfo.UseShellExecute = false;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ internal static class OfflineCommandRouter
|
|||||||
return args[0] switch
|
return args[0] switch
|
||||||
{
|
{
|
||||||
"configure" => RunConfigure(args.Skip(1).ToArray()),
|
"configure" => RunConfigure(args.Skip(1).ToArray()),
|
||||||
"secrets" => RunSecrets(args.Skip(1).ToArray(), credentials),
|
"secrets" => await RunSecretsAsync(args.Skip(1).ToArray(), credentials),
|
||||||
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
|
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
|
||||||
"doctor" when args.Length == 1 => RunDoctor(credentials),
|
"doctor" when args.Length == 1 => RunDoctor(credentials),
|
||||||
"validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials),
|
"validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials),
|
||||||
@@ -66,7 +66,7 @@ internal static class OfflineCommandRouter
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int RunSecrets(string[] args, ICredentialStore store)
|
private static async Task<int> RunSecretsAsync(string[] args, ICredentialStore store)
|
||||||
{
|
{
|
||||||
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
|
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
|
||||||
switch (args[0])
|
switch (args[0])
|
||||||
@@ -84,6 +84,10 @@ internal static class OfflineCommandRouter
|
|||||||
store.Write(target, value);
|
store.Write(target, value);
|
||||||
WriteResult("credential_saved", true);
|
WriteResult("credential_saved", true);
|
||||||
return 0;
|
return 0;
|
||||||
|
case "probe" when target == CredentialCatalog.ApiAmap:
|
||||||
|
return AmapProbe.Run(store.Read(target));
|
||||||
|
case "probe" when target == CredentialCatalog.WorkerAiGateway:
|
||||||
|
return await AiGatewayProbe.RunAsync(store);
|
||||||
default:
|
default:
|
||||||
return Usage();
|
return Usage();
|
||||||
}
|
}
|
||||||
@@ -199,7 +203,7 @@ internal static class OfflineCommandRouter
|
|||||||
|
|
||||||
private static int Usage()
|
private static int Usage()
|
||||||
{
|
{
|
||||||
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor; validate-external", false);
|
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear|probe; admin-allowlist add|remove|status; doctor; validate-external", false);
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Dada.Supervisor;
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
internal sealed class SupervisorRuntime : IAsyncDisposable
|
internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly string[] RequiredDataDirectories =
|
||||||
|
[
|
||||||
|
"db",
|
||||||
|
Path.Combine("content", "references"),
|
||||||
|
Path.Combine("content", "generated"),
|
||||||
|
Path.Combine("content", "exports"),
|
||||||
|
"managed-assets",
|
||||||
|
"derived-assets",
|
||||||
|
"staging",
|
||||||
|
Path.Combine("logs", "api"),
|
||||||
|
Path.Combine("logs", "worker"),
|
||||||
|
Path.Combine("logs", "supervisor"),
|
||||||
|
];
|
||||||
private readonly ICredentialStore credentials;
|
private readonly ICredentialStore credentials;
|
||||||
private ManagedComponentSupervisor? api;
|
private ManagedComponentSupervisor? api;
|
||||||
private ManagedComponentSupervisor? worker;
|
private ManagedComponentSupervisor? worker;
|
||||||
@@ -25,6 +39,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return SupervisorState.StartupFailed;
|
return SupervisorState.StartupFailed;
|
||||||
}
|
}
|
||||||
|
EnsureRuntimeDirectories(configuration.LocalDataRoot);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
|
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
|
||||||
@@ -34,16 +49,15 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return SupervisorState.StorageUnavailable;
|
return SupervisorState.StorageUnavailable;
|
||||||
}
|
}
|
||||||
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target)))
|
EnsureAdminPepper();
|
||||||
{
|
|
||||||
return SupervisorState.StartupFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
||||||
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||||
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
||||||
await api.StartAsync(cancellationToken);
|
await api.StartAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -51,6 +65,30 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
await worker.StartAsync(cancellationToken);
|
await worker.StartAsync(cancellationToken);
|
||||||
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
|
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await StopComponentsAsync();
|
||||||
|
return TryLog(new StructuredLogEvent("failed", ErrorCategory: "service_unavailable"))
|
||||||
|
? SupervisorState.StartupFailed
|
||||||
|
: SupervisorState.StorageUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void EnsureRuntimeDirectories(string dataRoot)
|
||||||
|
{
|
||||||
|
foreach (var directory in RequiredDataDirectories)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(dataRoot, directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAdminPepper()
|
||||||
|
{
|
||||||
|
if (credentials.IsConfigured(CredentialCatalog.AdminPepper)) return;
|
||||||
|
var pepper = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
credentials.Write(CredentialCatalog.AdminPepper, pepper);
|
||||||
|
Array.Clear(System.Text.Encoding.UTF8.GetBytes(pepper));
|
||||||
|
}
|
||||||
|
|
||||||
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
|
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
|
||||||
{
|
{
|
||||||
@@ -60,6 +98,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
||||||
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||||
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
||||||
|
startInfo.Environment["DADA_WEB_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web");
|
||||||
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
||||||
startInfo.ArgumentList.Add(entry);
|
startInfo.ArgumentList.Add(entry);
|
||||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||||
@@ -95,10 +134,26 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopComponentsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopComponentsAsync()
|
||||||
{
|
{
|
||||||
var stops = new List<Task>();
|
var stops = new List<Task>();
|
||||||
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
|
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
|
||||||
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
|
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
|
||||||
|
try
|
||||||
|
{
|
||||||
await Task.WhenAll(stops);
|
await Task.WhenAll(stops);
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
worker = null;
|
||||||
|
api = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModelConfigurationService,
|
||||||
|
portableRuntimeModelCandidates,
|
||||||
|
} from "../../apps/api/src/model-configuration.js";
|
||||||
|
|
||||||
|
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||||
|
const Database = requireFromApi("better-sqlite3") as new (path: string) => {
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POSTV1-02 portable runtime model seed", () => {
|
||||||
|
it("enables only models backed by the real OneAPI contract", () => {
|
||||||
|
const database = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
const models = new ModelConfigurationService({ database, seedCandidates: portableRuntimeModelCandidates }).read();
|
||||||
|
const flash = models.models.find((model) => model.model_id === "gemini-3.1-flash-image-preview");
|
||||||
|
const pro = models.models.find((model) => model.model_id === "gemini-3-pro-image-preview");
|
||||||
|
const gpt = models.models.find((model) => model.model_id === "gpt-image-2");
|
||||||
|
|
||||||
|
expect(models.configured_default_model_id).toBe("gemini-3.1-flash-image-preview");
|
||||||
|
expect(flash).toMatchObject({
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
enabled: true,
|
||||||
|
runtime_availability: { available_for_new_jobs: true, reason: "available" },
|
||||||
|
});
|
||||||
|
expect(flash?.route_profile).toMatchObject({ endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions" });
|
||||||
|
expect(pro).toMatchObject({
|
||||||
|
contract_validation_status: "unverified",
|
||||||
|
enabled: false,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, reason: "configured_disabled" },
|
||||||
|
});
|
||||||
|
expect(gpt).toMatchObject({
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
enabled: true,
|
||||||
|
runtime_availability: { available_for_new_jobs: true, reason: "available" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,7 +125,12 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
||||||
it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => {
|
it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => {
|
||||||
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
|
const app = await createApp({
|
||||||
|
browserSupportRelease: testBrowserSupportRelease,
|
||||||
|
productIndexHtml: "<!doctype html><title>Dada product test</title><div id=\"root\"></div>",
|
||||||
|
} as never);
|
||||||
|
const gate = await app.inject({ headers: { host: "127.0.0.1:43121" }, method: "GET", url: "/" });
|
||||||
|
expect(gate.body).toContain("当前浏览器无法使用 Dada");
|
||||||
const checked = await app.inject({
|
const checked = await app.inject({
|
||||||
headers: supportedEdge.headers,
|
headers: supportedEdge.headers,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -150,6 +155,14 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
|
|
||||||
const cookie = supportCookie(checked);
|
const cookie = supportCookie(checked);
|
||||||
expect(cookie).toBeDefined();
|
expect(cookie).toBeDefined();
|
||||||
|
const productHtml = await app.inject({
|
||||||
|
headers: { cookie, host: "127.0.0.1:43121", "sec-ch-ua": supportedEdge.headers["sec-ch-ua"] },
|
||||||
|
method: "GET",
|
||||||
|
url: "/app",
|
||||||
|
});
|
||||||
|
expect(productHtml.statusCode).toBe(200);
|
||||||
|
expect(productHtml.body).toContain("Dada product test");
|
||||||
|
expect(productHtml.body).not.toContain("当前浏览器无法使用 Dada");
|
||||||
const product = await app.inject({
|
const product = await app.inject({
|
||||||
headers: {
|
headers: {
|
||||||
cookie,
|
cookie,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RealAmapAdapter } from "../../apps/api/src/amap-adapter.js";
|
||||||
|
import { initializeApiCredentialClients } from "../../apps/api/src/supervisor-channel.js";
|
||||||
|
|
||||||
|
describe("TDD-WP7-EXT-003 production Amap adapter", () => {
|
||||||
|
it("uses the fixed HTTPS provider boundary and returns only the formatted location", async () => {
|
||||||
|
const request = vi.fn(async () => ({
|
||||||
|
regeocode: { formatted_address: "模拟省模拟市" },
|
||||||
|
status: "1",
|
||||||
|
}));
|
||||||
|
const adapter = new RealAmapAdapter("fixture-amap-value", { request });
|
||||||
|
|
||||||
|
await expect(adapter.reverseGeocode({ latitude: 12.3456, longitude: 65.4321 })).resolves.toEqual({
|
||||||
|
formattedValue: "模拟省模拟市",
|
||||||
|
serviceMode: "real",
|
||||||
|
});
|
||||||
|
expect(request).toHaveBeenCalledOnce();
|
||||||
|
expect(request.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
allowRedirects: false,
|
||||||
|
hostname: "restapi.amap.com",
|
||||||
|
maxResponseBytes: 65_536,
|
||||||
|
method: "GET",
|
||||||
|
protocol: "https:",
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
expect(request.mock.calls[0]?.[0].path).toContain("/v3/geocode/regeo?");
|
||||||
|
expect(request.mock.calls[0]?.[0].path).toContain("location=65.4321%2C12.3456");
|
||||||
|
adapter.dispose();
|
||||||
|
await expect(adapter.reverseGeocode({ latitude: 0, longitude: 0 })).rejects.toThrow("amap_adapter_disposed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs the real client from the API credential channel and clears the source values", () => {
|
||||||
|
const credentials = {
|
||||||
|
"Dada/P0A/admin/pepper": "fixture-admin-value",
|
||||||
|
"Dada/P0A/api/amap": "fixture-amap-value",
|
||||||
|
"Dada/P0A/api/resend": "fixture-resend-value",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clients = initializeApiCredentialClients(credentials);
|
||||||
|
expect(clients.amap).toBeInstanceOf(RealAmapAdapter);
|
||||||
|
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
||||||
|
clients.amap.dispose();
|
||||||
|
clients.adminAllowlistPepper.fill(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js";
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const registrations: RegistrationService[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const registration of registrations.splice(0)) registration.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createRegistration() {
|
||||||
|
const now = Date.parse("2026-08-04T09:00:00.000Z");
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp7-04-amap-"));
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
registrations.push(registration);
|
||||||
|
|
||||||
|
const userId = randomUUID();
|
||||||
|
registration.database.prepare(`
|
||||||
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
||||||
|
VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
||||||
|
`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
|
||||||
|
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Release Gate User', '@release_gate')").run(userId);
|
||||||
|
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||||
|
return { registration, session: registration.issueAuthenticatedSession(userId, "user") };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TDD-WP7-EXT-003 Amap release hard stop", () => {
|
||||||
|
it("admits the simulated 1000th request and blocks the 1001st before provider egress", async () => {
|
||||||
|
const { registration, session } = createRegistration();
|
||||||
|
registration.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET used_count = 999, service_status = 'active', pause_reason = NULL
|
||||||
|
WHERE service_id = 'amap_web_service' AND period_type = 'monthly'
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
const amap = new MockAmapAdapter();
|
||||||
|
const app = await createApp({ amap, browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
const headers = {
|
||||||
|
cookie: `dada_session=${session.sessionToken}`,
|
||||||
|
host: "127.0.0.1:43121",
|
||||||
|
origin: "http://127.0.0.1:43121",
|
||||||
|
"x-csrf-token": registration.issueUserCsrfToken(session.sessionToken),
|
||||||
|
};
|
||||||
|
|
||||||
|
const thousandth = await app.inject({
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: { latitude: 0, longitude: 0 },
|
||||||
|
url: "/api/v1/location/reverse-geocode",
|
||||||
|
});
|
||||||
|
const thousandAndFirst = await app.inject({
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: { latitude: 0, longitude: 0 },
|
||||||
|
url: "/api/v1/location/reverse-geocode",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(thousandth.statusCode).toBe(200);
|
||||||
|
expect(thousandAndFirst.statusCode).toBe(503);
|
||||||
|
expect(amap.calls).toHaveLength(1);
|
||||||
|
expect(registration.serviceUsage.read("amap_web_service")).toEqual([
|
||||||
|
expect.objectContaining({ hardLimit: 1_000, status: "paused_quota", usedCount: 1_000 }),
|
||||||
|
]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
|
||||||
|
const packageRoot = resolve(process.env.DADA_POSTV1_PACKAGE_ROOT ?? ".build/portable-release/Dada-P0A-0.0.0-win-x64");
|
||||||
|
const port = 43121;
|
||||||
|
|
||||||
|
async function waitForHealth(child) {
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`packaged api exited: ${child.exitCode}: ${child.errorOutput ?? ""}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { headers: { host: `127.0.0.1:${port}` } });
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||||
|
}
|
||||||
|
throw new Error("packaged api health timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startApi(configPath, dataRoot) {
|
||||||
|
const child = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs"), "--dada-credential-stdin"], {
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: { ...process.env, DADA_INSTANCE_CONFIG_PATH: configPath, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate"), DADA_WEB_ROOT: join(packageRoot, "web") },
|
||||||
|
stdio: ["pipe", "ignore", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
child.errorOutput = "";
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stderr.on("data", (chunk) => { child.errorOutput += chunk; });
|
||||||
|
child.stdin.end(JSON.stringify({ "Dada/P0A/api/amap": "", "Dada/P0A/api/resend": "", "Dada/P0A/admin/pepper": "portable-test-pepper-00000000000000000000000000000000" }));
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(child) {
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
child.kill();
|
||||||
|
await new Promise((resolveExit) => child.once("exit", resolveExit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWorkerStartup(configPath) {
|
||||||
|
const pipeName = `Dada.P0A.PostV1.${process.pid}.${Date.now()}`;
|
||||||
|
const pipePath = `\\\\.\\pipe\\${pipeName}`;
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise((resolveListen, rejectListen) => {
|
||||||
|
server.once("error", rejectListen);
|
||||||
|
server.listen(pipePath, resolveListen);
|
||||||
|
});
|
||||||
|
const child = spawn(join(packageRoot, "runtime", "node.exe"), [
|
||||||
|
join(packageRoot, "server", "worker.mjs"),
|
||||||
|
"--dada-control-pipe", pipeName,
|
||||||
|
"--dada-credential-stdin",
|
||||||
|
], {
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DADA_INSTANCE_CONFIG_PATH: configPath,
|
||||||
|
DADA_SQLITE_NATIVE_BINDING: join(packageRoot, "server", "native", "better_sqlite3.node"),
|
||||||
|
},
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
child.stdin.end(JSON.stringify({ "Dada/P0A/worker/ai-gateway": "synthetic-runtime-token" }));
|
||||||
|
try {
|
||||||
|
await new Promise((resolveReady, rejectReady) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(deadline);
|
||||||
|
child.off("exit", onExit);
|
||||||
|
if (error) rejectReady(error); else resolveReady();
|
||||||
|
};
|
||||||
|
const deadline = setTimeout(() => finish(new Error("packaged worker ready timeout")), 15_000);
|
||||||
|
const onExit = (code) => finish(new Error(`packaged worker exited before ready: ${code}`));
|
||||||
|
child.once("exit", onExit);
|
||||||
|
server.once("connection", (connection) => {
|
||||||
|
connection.setEncoding("utf8");
|
||||||
|
let pending = "";
|
||||||
|
connection.on("data", (chunk) => {
|
||||||
|
pending += chunk;
|
||||||
|
while (pending.includes("\n")) {
|
||||||
|
const newline = pending.indexOf("\n");
|
||||||
|
const status = pending.slice(0, newline).trim();
|
||||||
|
pending = pending.slice(newline + 1);
|
||||||
|
if (status === "storage_unavailable") finish(new Error("packaged worker reported storage_unavailable"));
|
||||||
|
if (status === "ready") {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
connection.write("shutdown\n");
|
||||||
|
finish();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit));
|
||||||
|
assert.equal(exitCode, 0);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null) child.kill();
|
||||||
|
await new Promise((resolveClose) => server.close(resolveClose));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
||||||
|
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
||||||
|
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||||
|
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||||
|
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||||
|
assert.match(packagedWorker, /GenerationProcessor/);
|
||||||
|
assert.match(packagedWorker, /OneApiGenerationAdapter/);
|
||||||
|
assert.match(packagedOneApiAdapter, /oneapi\.intelligrow\.cn/);
|
||||||
|
assert.doesNotMatch(packagedWorker, /portable-mock-worker/);
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-postv1-"));
|
||||||
|
const dataRoot = join(root, "data");
|
||||||
|
const configPath = join(root, "instance.json");
|
||||||
|
await writeFile(configPath, JSON.stringify({ data_root: dataRoot, initialized: true, instance_id: "portable-test", schema_version: 1, secure_config_revision: 1, admin_allowlist_hashes: [], admin_recovery_hashes: [] }));
|
||||||
|
let api = startApi(configPath, dataRoot);
|
||||||
|
try {
|
||||||
|
await waitForHealth(api);
|
||||||
|
const initialPage = await fetch(`http://127.0.0.1:${port}/`, { headers: { host: `127.0.0.1:${port}` } });
|
||||||
|
assert.equal(initialPage.status, 200);
|
||||||
|
assert.match(await initialPage.text(), /当前浏览器无法使用 Dada/);
|
||||||
|
const support = await fetch(`http://127.0.0.1:${port}/api/v1/support/check`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, "content-type": "application/json", "sec-ch-ua": '"Google Chrome";v="150"', "sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"', "sec-ch-ua-platform": '"Windows"' },
|
||||||
|
body: JSON.stringify({ brands: [{ brand: "Google Chrome", version: "150" }], full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }], platform: "Windows" }),
|
||||||
|
});
|
||||||
|
assert.equal(support.status, 200);
|
||||||
|
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
|
||||||
|
assert.ok(cookie);
|
||||||
|
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' } });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
const pageHtml = await page.text();
|
||||||
|
assert.match(pageHtml, /<div id="root"><\/div>/);
|
||||||
|
const scriptPath = pageHtml.match(/<script[^>]+src="([^"]+)"/)?.[1];
|
||||||
|
const stylesheetPath = pageHtml.match(/<link[^>]+href="([^"]+)"/)?.[1];
|
||||||
|
assert.ok(scriptPath);
|
||||||
|
assert.ok(stylesheetPath);
|
||||||
|
const assetHeaders = { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' };
|
||||||
|
const script = await fetch(`http://127.0.0.1:${port}${scriptPath}`, { headers: assetHeaders });
|
||||||
|
const stylesheet = await fetch(`http://127.0.0.1:${port}${stylesheetPath}`, { headers: assetHeaders });
|
||||||
|
assert.equal(script.status, 200);
|
||||||
|
assert.match(script.headers.get("content-type") ?? "", /^(?:application|text)\/javascript\b/);
|
||||||
|
assert.equal(stylesheet.status, 200);
|
||||||
|
assert.match(stylesheet.headers.get("content-type") ?? "", /^text\/css\b/);
|
||||||
|
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
|
||||||
|
await verifyWorkerStartup(configPath);
|
||||||
|
} finally {
|
||||||
|
await stop(api);
|
||||||
|
}
|
||||||
|
api = startApi(configPath, dataRoot);
|
||||||
|
try {
|
||||||
|
await waitForHealth(api);
|
||||||
|
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
|
||||||
|
} finally {
|
||||||
|
await stop(api);
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildCoverageEvidence } from '../../scripts/lib/wp7-05-coverage.mjs';
|
||||||
|
import { REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 },
|
||||||
|
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function completeUnits() {
|
||||||
|
return REQUIRED_COVERAGE_UNITS.map((page_id) => ({
|
||||||
|
page_id,
|
||||||
|
states: [{
|
||||||
|
state: 'normal',
|
||||||
|
screenshot_100pct: `ui/${page_id}/normal/100pct.png`,
|
||||||
|
screenshot_200pct: `ui/${page_id}/normal/200pct.png`,
|
||||||
|
trace: `ui/${page_id}/normal/trace.zip`,
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('builds ordered, sanitized evidence for all 22 coverage units', () => {
|
||||||
|
const evidence = buildCoverageEvidence({
|
||||||
|
runId: 'wp7-05-red-001',
|
||||||
|
candidateSha256: 'a'.repeat(64),
|
||||||
|
coverageUnits: completeUnits(),
|
||||||
|
viewports,
|
||||||
|
});
|
||||||
|
assert.equal(evidence.coverage_units.length, 22);
|
||||||
|
assert.equal(evidence.coverage_units[0].page_id, 'support-gate');
|
||||||
|
assert.equal(evidence.candidate_sha256, 'A'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects absolute evidence paths and incomplete page state', () => {
|
||||||
|
const units = completeUnits();
|
||||||
|
units[0].states[0].trace = 'C:\\secret\\trace.zip';
|
||||||
|
assert.throws(() => buildCoverageEvidence({
|
||||||
|
runId: 'wp7-05-red-001', candidateSha256: 'a'.repeat(64), coverageUnits: units, viewports,
|
||||||
|
}), /WP7_05_UNSAFE_TRACE/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { loadCandidateRecord, validateCoverageEvidence, runWp705Gate, REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs';
|
||||||
|
|
||||||
|
test('WP7-05 accepts the WP7-01 candidate record schema', () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wp7-05-'));
|
||||||
|
const file = path.join(directory, 'release-candidate.json');
|
||||||
|
fs.writeFileSync(file, JSON.stringify({
|
||||||
|
browsers: [
|
||||||
|
{ brand: 'Google Chrome', full_version: '150.0.0', major: 150 },
|
||||||
|
{ brand: 'Microsoft Edge', full_version: '151.0.0', major: 151 },
|
||||||
|
],
|
||||||
|
windows: { build: '26200.8875' },
|
||||||
|
candidate_package: { fixed_port: 43121, sha256: 'A'.repeat(64) },
|
||||||
|
}));
|
||||||
|
assert.equal(loadCandidateRecord(file).status, 'ready');
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 requires all 22 coverage units and both exact candidate viewports', () => {
|
||||||
|
const result = validateCoverageEvidence({ coverage_units: [], viewports: [] });
|
||||||
|
assert.equal(result.status, 'externally_blocked');
|
||||||
|
assert.equal(result.code, 'coverage_units_incomplete');
|
||||||
|
assert.equal(result.missing.length, REQUIRED_COVERAGE_UNITS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 requires state evidence for every listed coverage unit', () => {
|
||||||
|
const result = validateCoverageEvidence({
|
||||||
|
coverage_units: REQUIRED_COVERAGE_UNITS.map((page_id) => ({ page_id, states: [] })),
|
||||||
|
viewports: [],
|
||||||
|
});
|
||||||
|
assert.equal(result.code, 'coverage_states_incomplete');
|
||||||
|
assert.equal(result.missingStates.length, REQUIRED_COVERAGE_UNITS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WP7-05 preserves upstream external blockers instead of declaring Green', () => {
|
||||||
|
const result = runWp705Gate({
|
||||||
|
candidatePath: 'missing-release-candidate.json',
|
||||||
|
evidence: null,
|
||||||
|
dependencies: { 'TASK-WP7-03': 'externally_blocked' },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'externally_blocked');
|
||||||
|
assert.equal(result.code, 'candidate_record_missing');
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { buildWp706PrefreezeReport } from "../../scripts/lib/wp7-06-prefreeze.mjs";
|
||||||
|
|
||||||
|
const expectedTrace = {
|
||||||
|
errors: [],
|
||||||
|
status: "passed",
|
||||||
|
summary: {
|
||||||
|
acceptanceCriteria: 52,
|
||||||
|
errorCategories: 9,
|
||||||
|
featureModules: 13,
|
||||||
|
parentFamilies: 89,
|
||||||
|
penProductFrames: 18,
|
||||||
|
productContracts: 19,
|
||||||
|
requirements: 109,
|
||||||
|
tasks: 52,
|
||||||
|
testCases: 117,
|
||||||
|
uiPages: 22,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const upstream = {
|
||||||
|
"TASK-WP7-01": { branch: "codex/wp7-01", head: "1".repeat(40), merged: true, status: "passed" },
|
||||||
|
"TASK-WP7-02": { branch: "codex/wp7-02", head: "2".repeat(40), merged: true, status: "passed" },
|
||||||
|
"TASK-WP7-03": { branch: "codex/wp7-03", head: "3".repeat(40), merged: true, status: "deferred_nonblocking_first_version" },
|
||||||
|
"TASK-WP7-04": { branch: "codex/wp7-04", head: "4".repeat(40), merged: true, status: "deferred_nonblocking_first_version" },
|
||||||
|
"TASK-WP7-05": { branch: "codex/wp7-05", head: "5".repeat(40), merged: true, status: "passed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
test("builds the exact WP7-06 pre-freeze closure without pretending deferred suppliers passed", () => {
|
||||||
|
const report = buildWp706PrefreezeReport({
|
||||||
|
currentCommit: "a".repeat(40),
|
||||||
|
releaseExists: false,
|
||||||
|
trace: expectedTrace,
|
||||||
|
upstream,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(report.status, "passed");
|
||||||
|
assert.equal(report.release_json_written, false);
|
||||||
|
assert.deepEqual(report.trace_summary, expectedTrace.summary);
|
||||||
|
assert.deepEqual(report.deferred_external_tasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing lineage and premature RELEASE.json", () => {
|
||||||
|
assert.throws(() => buildWp706PrefreezeReport({
|
||||||
|
currentCommit: "a".repeat(40),
|
||||||
|
releaseExists: false,
|
||||||
|
trace: expectedTrace,
|
||||||
|
upstream: { ...upstream, "TASK-WP7-05": { ...upstream["TASK-WP7-05"], merged: false } },
|
||||||
|
}), /WP7_06_UPSTREAM_NOT_MERGED:TASK-WP7-05/);
|
||||||
|
|
||||||
|
assert.throws(() => buildWp706PrefreezeReport({
|
||||||
|
currentCommit: "a".repeat(40),
|
||||||
|
releaseExists: true,
|
||||||
|
trace: expectedTrace,
|
||||||
|
upstream,
|
||||||
|
}), /WP7_06_RELEASE_WRITTEN_PREMATURELY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects trace drift and unsupported upstream statuses", () => {
|
||||||
|
assert.throws(() => buildWp706PrefreezeReport({
|
||||||
|
currentCommit: "a".repeat(40),
|
||||||
|
releaseExists: false,
|
||||||
|
trace: { ...expectedTrace, summary: { ...expectedTrace.summary, testCases: 116 } },
|
||||||
|
upstream,
|
||||||
|
}), /WP7_06_TRACE_COUNT_MISMATCH:testCases/);
|
||||||
|
|
||||||
|
assert.throws(() => buildWp706PrefreezeReport({
|
||||||
|
currentCommit: "a".repeat(40),
|
||||||
|
releaseExists: false,
|
||||||
|
trace: expectedTrace,
|
||||||
|
upstream: { ...upstream, "TASK-WP7-03": { ...upstream["TASK-WP7-03"], status: "passed" } },
|
||||||
|
}), /WP7_06_UNSUPPORTED_STATUS:TASK-WP7-03/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { buildFinalReleaseRecord, scanReleaseFiles, validateFinalEvidence } from "../../scripts/lib/wp7-07-final-release.mjs";
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
return buildFinalReleaseRecord({
|
||||||
|
appVersion: "0.0.0",
|
||||||
|
browsers: [
|
||||||
|
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
|
||||||
|
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
|
||||||
|
],
|
||||||
|
buildCommit: "a".repeat(40),
|
||||||
|
frozenFromCommit: "b".repeat(40),
|
||||||
|
recordedAt: "2026-08-04T06:00:00.000Z",
|
||||||
|
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", () => {
|
||||||
|
const record = release();
|
||||||
|
assert.equal(record.finalRelease, true);
|
||||||
|
assert.equal(record.fixedPort, 43121);
|
||||||
|
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
|
||||||
|
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp7-07-scan-"));
|
||||||
|
mkdirSync(join(root, "logs"));
|
||||||
|
writeFileSync(join(root, "logs", "diagnostic.txt"), "credential=key-abcdefghijklmnop C:\\Users\\person\\private.txt\n");
|
||||||
|
const scan = scanReleaseFiles({ roots: [root] });
|
||||||
|
assert.equal(scan.status, "failed");
|
||||||
|
assert.deepEqual(new Set(scan.findings.map(({ rule }) => rule)), new Set(["absolute_user_path", "credential_shape"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP7-REL-001 binds release and package hashes only after a zero-finding scan", () => {
|
||||||
|
assert.deepEqual(validateFinalEvidence({
|
||||||
|
packageManifest: { release_status: "first_version_internal", zip_sha256: "C".repeat(64) },
|
||||||
|
release: release(),
|
||||||
|
releaseSha256: "D".repeat(64),
|
||||||
|
scan: { findings: [], status: "passed" },
|
||||||
|
}), { release_sha256: "D".repeat(64), status: "passed", zip_sha256: "C".repeat(64) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { GenerationAdapterRequest } from "../../apps/worker/src/ai-adapter-contract.js";
|
||||||
|
import { runAiRuntimeProbe } from "../../apps/worker/src/ai-runtime-probe.js";
|
||||||
|
import { OneApiGenerationAdapter } from "../../apps/worker/src/oneapi-generation-adapter.js";
|
||||||
|
|
||||||
|
function request(overrides: Partial<GenerationAdapterRequest> = {}): GenerationAdapterRequest {
|
||||||
|
return {
|
||||||
|
configSnapshot: {},
|
||||||
|
generationId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt: "一张用于本机验收的抽象色彩图",
|
||||||
|
ratio: "1:1",
|
||||||
|
referenceAssetIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POSTV1-02 OneAPI runtime adapter", () => {
|
||||||
|
it("uses the fixed Gemini gateway and normalizes one real-shaped response", async () => {
|
||||||
|
const source = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const gateway = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||||
|
expect(init?.redirect).toBe("error");
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
choices: [{ message: { content: `})` } }],
|
||||||
|
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||||
|
});
|
||||||
|
const credential = Buffer.from("synthetic-runtime-token");
|
||||||
|
const adapter = new OneApiGenerationAdapter({ credential, fetch: gateway as typeof fetch });
|
||||||
|
const result = await adapter.start(request());
|
||||||
|
|
||||||
|
expect(gateway).toHaveBeenCalledOnce();
|
||||||
|
expect(gateway.mock.calls[0]?.[0]).toBe("https://oneapi.intelligrow.cn/v1/chat/completions");
|
||||||
|
expect(result.status === "failed" ? result.sourceCategory : "completed").toBe("completed");
|
||||||
|
if (result.status === "completed") {
|
||||||
|
expect(result.outputs).toHaveLength(1);
|
||||||
|
expect(result.outputs[0]).toMatchObject({ mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 });
|
||||||
|
expect(result.outputs[0]?.bytes.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(result)).not.toContain("synthetic-runtime-token");
|
||||||
|
adapter.dispose();
|
||||||
|
credential.fill(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed instead of returning a mock image", async () => {
|
||||||
|
const adapter = new OneApiGenerationAdapter({
|
||||||
|
credential: Buffer.from("synthetic-runtime-token"),
|
||||||
|
fetch: vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch,
|
||||||
|
});
|
||||||
|
await expect(adapter.start(request())).resolves.toEqual({
|
||||||
|
category: "upstream_failed",
|
||||||
|
sourceCategory: "upstream_http_503",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
adapter.dispose();
|
||||||
|
await expect(adapter.start(request())).resolves.toEqual({
|
||||||
|
category: "upstream_failed",
|
||||||
|
sourceCategory: "adapter_disposed",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only a bounded probe summary and wipes generated bytes", async () => {
|
||||||
|
const bytes = Buffer.from("probe-output");
|
||||||
|
const result = await runAiRuntimeProbe({
|
||||||
|
async start() {
|
||||||
|
return { outputs: [{ bytes, mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 }], status: "completed" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
code: "ai_probe_passed",
|
||||||
|
mime_type: "image/png",
|
||||||
|
pixel_height: 1080,
|
||||||
|
pixel_width: 1080,
|
||||||
|
real_calls: 1,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(bytes.every((value) => value === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user