239 lines
7.9 KiB
JavaScript
Executable File
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));
|
|
}
|
|
}
|