feat: publish workphone public SDK deployment and API docs
This commit is contained in:
198
sdk/scripts/run_stability_longrun.mjs
Executable file
198
sdk/scripts/run_stability_longrun.mjs
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/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,
|
||||
outputDir: "",
|
||||
};
|
||||
const mapping = {
|
||||
"--base": "base",
|
||||
"--device-id": "deviceId",
|
||||
"--interval-seconds": "intervalSeconds",
|
||||
"--timeout-seconds": "timeoutSeconds",
|
||||
"--output-dir": "outputDir",
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = mapping[argv[index]];
|
||||
if (!key || 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");
|
||||
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()}`,
|
||||
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),
|
||||
wechat_version: probe.wechat_version || "",
|
||||
probe_latency_ms: probe.latency_ms || 0,
|
||||
error: probe.error || "",
|
||||
});
|
||||
row.ok = Boolean(row.sdk_healthy && row.ws_online && 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);
|
||||
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,
|
||||
samples_file: samplesPath,
|
||||
runner: "node",
|
||||
});
|
||||
if (!stopping) {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(args.intervalSeconds, 5) * 1000));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user