Files
workphone-sdk/sdk/scripts/run_stability_longrun.mjs
2026-07-23 23:40:57 +08:00

239 lines
7.9 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
function parseArgs(argv) {
const args = {
base: "http://127.0.0.1:8899",
deviceId: "xgfe65eimrrofyws",
intervalSeconds: 60,
timeoutSeconds: 45,
requireHook: false,
outputDir: "",
};
const mapping = {
"--base": "base",
"--device-id": "deviceId",
"--interval-seconds": "intervalSeconds",
"--timeout-seconds": "timeoutSeconds",
"--output-dir": "outputDir",
"--require-hook": "requireHook",
};
for (let index = 0; index < argv.length; index += 2) {
const key = mapping[argv[index]];
if (!key) {
throw new Error(`invalid argument: ${argv[index] ?? ""}`);
}
if (key === "requireHook") {
args[key] = true;
continue;
}
if (argv[index + 1] === undefined) throw new Error(`invalid argument: ${argv[index] ?? ""}`);
args[key] = argv[index + 1];
}
args.intervalSeconds = Number(args.intervalSeconds);
args.timeoutSeconds = Number(args.timeoutSeconds);
if (!args.outputDir) {
throw new Error("--output-dir is required");
}
return args;
}
async function requestJson(url, timeoutSeconds) {
const response = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "workphone-stability/2.0" },
signal: AbortSignal.timeout(timeoutSeconds * 1000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${url}`);
}
return response.json();
}
async function writeJson(filePath, value) {
const tempPath = `${filePath}.tmp`;
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(tempPath, filePath);
}
async function restoreSamples(samplesPath) {
const state = {
startedAt: new Date(),
total: 0,
ok: 0,
failures: 0,
offlineEvents: 0,
reconnects: 0,
latencyTotal: 0,
maxLatency: 0,
lastOk: null,
lastError: "",
};
let content = "";
try {
content = await readFile(samplesPath, "utf8");
} catch (error) {
if (error.code !== "ENOENT") throw error;
return state;
}
for (const line of content.split("\n")) {
if (!line.trim()) continue;
let row;
try {
row = JSON.parse(line);
} catch {
continue;
}
if (!row || typeof row !== "object") continue;
if (state.total === 0) {
const firstTimestamp = new Date(row.timestamp);
if (!Number.isNaN(firstTimestamp.getTime())) state.startedAt = firstTimestamp;
}
const rowOk = Boolean(row.ok);
const elapsedMs = Number(row.request_elapsed_ms) || 0;
state.total += 1;
state.ok += Number(rowOk);
state.failures += Number(!rowOk);
state.latencyTotal += elapsedMs;
state.maxLatency = Math.max(state.maxLatency, elapsedMs);
if (rowOk) {
if (state.lastOk === false) state.reconnects += 1;
state.lastError = "";
} else {
if (state.lastOk !== false) state.offlineEvents += 1;
state.lastError = String(row.error || "sdk/ws/hook not ready");
}
state.lastOk = rowOk;
}
return state;
}
const args = parseArgs(process.argv.slice(2));
const outputDir = path.resolve(args.outputDir);
const samplesPath = path.join(outputDir, "stability_samples.jsonl");
const summaryPath = path.join(outputDir, "stability_latest_summary.json");
const eventsPath = path.join(outputDir, "stability_recovery_events.jsonl");
await mkdir(outputDir, { recursive: true });
const state = await restoreSamples(samplesPath);
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
});
process.on("SIGINT", () => {
stopping = true;
});
while (!stopping) {
const sampleStarted = Date.now();
const now = new Date();
const row = {
timestamp: now.toISOString(),
device_id: args.deviceId,
ok: false,
};
try {
const health = await requestJson(`${args.base}/health`, args.timeoutSeconds);
const query = new URLSearchParams({
device_id: args.deviceId,
samples: "1",
interval_seconds: "0",
});
const watch = await requestJson(
`${args.base}/api/v3/stability/watch?${query.toString()}&include_hook=${args.requireHook}`,
args.timeoutSeconds,
);
const probe = Array.isArray(watch?.data?.samples) ? watch.data.samples[0] ?? {} : {};
Object.assign(row, {
sdk_healthy: health.status === "healthy",
devices_online: health.devices_online,
ws_online: Boolean(probe.ws_online),
adb_online: Boolean(probe.adb_online),
hook_ok: Boolean(probe.hook_ok),
hook_probe_included: Boolean(probe.hook_probe_included),
wechat_running: probe.wechat_running,
wechat_foreground: probe.wechat_foreground,
foreground_service: probe.foreground_service,
agent_running: probe.agent_running,
network_type: probe.network_type || "",
last_heartbeat: probe.last_heartbeat,
heartbeat_age_seconds: probe.heartbeat_age_seconds,
heartbeat_stale: probe.heartbeat_stale,
connect_stage: probe.connect_stage || "",
recovery_reason: probe.recovery_reason || "",
wechat_version: probe.wechat_version || "",
probe_latency_ms: probe.latency_ms || 0,
error: probe.error || "",
});
const connectionOk = Boolean(row.sdk_healthy && row.ws_online && !row.heartbeat_stale);
row.ok = Boolean(connectionOk && (!args.requireHook || row.hook_ok));
} catch (error) {
row.error = String(error?.message || error).slice(0, 500);
}
row.request_elapsed_ms = Date.now() - sampleStarted;
state.total += 1;
state.latencyTotal += row.request_elapsed_ms;
state.maxLatency = Math.max(state.maxLatency, row.request_elapsed_ms);
if (row.ok) {
state.ok += 1;
if (state.lastOk === false) state.reconnects += 1;
state.lastError = "";
} else {
state.failures += 1;
state.lastError = String(row.error || "sdk/ws/hook not ready");
if (state.lastOk !== false) state.offlineEvents += 1;
}
state.lastOk = Boolean(row.ok);
let previousOk = null;
try {
const previousLines = (await readFile(samplesPath, "utf8")).trim().split("\n").filter(Boolean);
if (previousLines.length) previousOk = Boolean(JSON.parse(previousLines.at(-1)).ok);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
if (previousOk !== Boolean(row.ok)) {
await appendFile(eventsPath, `${JSON.stringify({
timestamp: row.timestamp,
device_id: args.deviceId,
event: row.ok ? "recovered" : "offline_started",
reason: row.error || row.recovery_reason || (row.ok ? "connection_ok" : "sdk/ws/heartbeat not ready"),
network_type: row.network_type || "",
connect_stage: row.connect_stage || "",
heartbeat_age_seconds: row.heartbeat_age_seconds,
})}\n`, "utf8");
}
await appendFile(samplesPath, `${JSON.stringify(row)}\n`, "utf8");
const elapsedSeconds = Math.max((now.getTime() - state.startedAt.getTime()) / 1000, 0);
await writeJson(summaryPath, {
started_at: state.startedAt.toISOString(),
updated_at: now.toISOString(),
elapsed_seconds: Math.trunc(elapsedSeconds),
elapsed_hours: Number((elapsedSeconds / 3600).toFixed(3)),
device_id: args.deviceId,
base: args.base,
interval_seconds: args.intervalSeconds,
total_samples: state.total,
ok_samples: state.ok,
failed_samples: state.failures,
success_rate: state.total ? Number((state.ok / state.total).toFixed(6)) : 0,
offline_events: state.offlineEvents,
reconnects: state.reconnects,
average_request_elapsed_ms: state.total
? Number((state.latencyTotal / state.total).toFixed(2))
: 0,
max_request_elapsed_ms: state.maxLatency,
last_ok: Boolean(row.ok),
last_error: state.lastError,
acceptance_24h_complete: elapsedSeconds >= 86400 && state.failures === 0,
hook_required: args.requireHook,
recovery_events_file: eventsPath,
samples_file: samplesPath,
runner: "node",
});
if (!stopping) {
await new Promise((resolve) => setTimeout(resolve, Math.max(args.intervalSeconds, 5) * 1000));
}
}