feat: 建立 ApiState Temporal native 开发链
This commit is contained in:
+116
-108
@@ -1,7 +1,12 @@
|
||||
import { AdminHttpClient } from "../../src/admin-http-client";
|
||||
import { mergeAccountScores } from "../../src/account-score-aggregation";
|
||||
import { createEmbeddedContext } from "../../src/bootstrap";
|
||||
import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget } from "../../src/config";
|
||||
import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget, type NativeServiceId } from "../../src/config";
|
||||
import type { AppCommand } from "../../src/contracts";
|
||||
import { usesWorkflow } from "../../src/contracts";
|
||||
import { ApplicationDispatcher } from "../../src/dispatcher";
|
||||
import { nativeLogs, nativeStart, nativeStatus, nativeStop } from "../../src/native-services";
|
||||
import { TemporalGateway } from "../../src/temporal-client";
|
||||
|
||||
interface Parsed {
|
||||
configPath: string;
|
||||
@@ -9,9 +14,13 @@ interface Parsed {
|
||||
command: string[];
|
||||
confirm: boolean;
|
||||
includeRecords: boolean;
|
||||
overApi: boolean;
|
||||
json: boolean;
|
||||
id: string | null;
|
||||
component: NativeServiceId | null;
|
||||
limit: number | null;
|
||||
draws: number | null;
|
||||
tail: number | null;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
@@ -29,8 +38,8 @@ function value(args: string[], name: string): string | null {
|
||||
function parseArgs(args: string[]): Parsed {
|
||||
const configPath = value(args, "--config");
|
||||
if (!configPath) throw new Error("--config is required");
|
||||
const optionNames = new Set(["--config", "--target", "--id", "--limit", "--draws"]);
|
||||
const flags = new Set(["--confirm", "--include-records"]);
|
||||
const optionNames = new Set(["--config", "--target", "--id", "--limit", "--draws", "--component", "--tail"]);
|
||||
const flags = new Set(["--confirm", "--include-records", "--over-api", "--json"]);
|
||||
const command: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const item = args[index]!;
|
||||
@@ -46,56 +55,90 @@ function parseArgs(args: string[]): Parsed {
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`);
|
||||
return parsed;
|
||||
};
|
||||
const component = value(args, "--component");
|
||||
if (component !== null && component !== "api" && component !== "worker" && component !== "web") throw new Error("--component must be api, worker, or web");
|
||||
return {
|
||||
configPath,
|
||||
targetId: value(args, "--target"),
|
||||
command,
|
||||
confirm: args.includes("--confirm"),
|
||||
includeRecords: args.includes("--include-records"),
|
||||
overApi: args.includes("--over-api"),
|
||||
json: args.includes("--json"),
|
||||
id: value(args, "--id"),
|
||||
component,
|
||||
limit: integer("--limit"),
|
||||
draws: integer("--draws"),
|
||||
tail: integer("--tail"),
|
||||
};
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
usage: "bun scripts/apistate-cli.ts --config config/sub2rank.yaml [--target local|production] <command>",
|
||||
usage: "bun scripts/apistate-cli.ts --config config/sub2rank.yaml [--over-api] [--target <id>] <command>",
|
||||
commands: [
|
||||
"config validate",
|
||||
"backend check",
|
||||
"scores aggregate-smoke",
|
||||
"lottery status",
|
||||
"lottery draw [--confirm]",
|
||||
"lottery reset [--draws N] [--include-records] --confirm",
|
||||
"records list [--limit N]",
|
||||
"records delete --id <record-id> --confirm",
|
||||
"credit test [--confirm]",
|
||||
"api smoke",
|
||||
"scores get|refresh|aggregate-smoke",
|
||||
"lottery status|draw|reset",
|
||||
"records list|delete",
|
||||
"credit test",
|
||||
"api smoke --over-api",
|
||||
"native start|stop|status|logs --component api|worker|web [--tail N]",
|
||||
],
|
||||
output: "k8s-style text by default; add --json for machine output",
|
||||
};
|
||||
}
|
||||
|
||||
function emit(value: Record<string, unknown>, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log("APISTATE RESULT");
|
||||
console.log("FIELD VALUE");
|
||||
console.log("-------------------- ------------------------------------------------------------");
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const rendered = item === null || item === undefined ? "-" : typeof item === "object" ? JSON.stringify(item) : String(item);
|
||||
console.log(`${key.padEnd(20)} ${rendered}`);
|
||||
}
|
||||
}
|
||||
|
||||
function appCommand(parsed: Parsed, config: ReturnType<typeof loadConfig>): AppCommand | Record<string, unknown> {
|
||||
const [group, action] = parsed.command;
|
||||
if (group === "backend" && action === "check") return { kind: "backend.check" };
|
||||
if (group === "scores" && action === "get") return { kind: "scores.get" };
|
||||
if (group === "scores" && action === "refresh") return { kind: "scores.refresh" };
|
||||
if (group === "lottery" && action === "status") return { kind: "lottery.status" };
|
||||
if (group === "lottery" && action === "draw") return parsed.confirm ? { kind: "lottery.draw" } : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" };
|
||||
if (group === "lottery" && action === "reset") {
|
||||
const draws = parsed.draws ?? config.lottery.initialDrawCount;
|
||||
return parsed.confirm ? { kind: "lottery.reset", draws, includeRecords: parsed.includeRecords } : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "records" && action === "list") return { kind: "records.list", limit: parsed.limit ?? config.records.publicLimit };
|
||||
if (group === "records" && action === "delete") {
|
||||
if (!parsed.id) throw new Error("records delete requires --id");
|
||||
return parsed.confirm ? { kind: "records.delete", id: parsed.id } : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "credit" && action === "test") return { kind: "credit.test", execute: parsed.confirm };
|
||||
throw new Error(`unknown command: ${parsed.command.join(" ")}`);
|
||||
}
|
||||
|
||||
function isAppCommand(value: AppCommand | Record<string, unknown>): value is AppCommand {
|
||||
return typeof value.kind === "string";
|
||||
}
|
||||
|
||||
async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: EmbeddedCliTarget): Promise<unknown> {
|
||||
const command = appCommand(parsed, config);
|
||||
if (!isAppCommand(command)) return command;
|
||||
const context = createEmbeddedContext(config, target);
|
||||
let temporal: TemporalGateway | null = null;
|
||||
try {
|
||||
const [group, action] = parsed.command;
|
||||
if (group === "backend" && action === "check") return await context.service.status(true);
|
||||
if (group === "lottery" && action === "status") return await context.service.status(false);
|
||||
if (group === "lottery" && action === "draw") return parsed.confirm ? { ok: true, record: await context.service.draw() } : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" };
|
||||
if (group === "lottery" && action === "reset") {
|
||||
const draws = parsed.draws ?? config.lottery.initialDrawCount;
|
||||
return parsed.confirm ? context.service.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "records" && action === "list") return { ok: true, records: context.service.listRecords(parsed.limit ?? config.records.publicLimit) };
|
||||
if (group === "records" && action === "delete") {
|
||||
if (!parsed.id) throw new Error("records delete requires --id");
|
||||
return parsed.confirm ? context.service.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "credit" && action === "test") return await context.service.creditTest(parsed.confirm);
|
||||
throw new Error(`unknown command: ${parsed.command.join(" ")}`);
|
||||
if (usesWorkflow(command)) temporal = await TemporalGateway.connect(config);
|
||||
return await new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal).dispatch(command);
|
||||
} finally {
|
||||
if (temporal) await temporal.close();
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
@@ -104,6 +147,8 @@ async function remote(parsed: Parsed, config: ReturnType<typeof loadConfig>, tar
|
||||
const client = new AdminHttpClient(config, target);
|
||||
const [group, action] = parsed.command;
|
||||
if (group === "backend" && action === "check") return await client.backendCheck();
|
||||
if (group === "scores" && action === "get") return await client.scores();
|
||||
if (group === "scores" && action === "refresh") return await client.refreshScores();
|
||||
if (group === "lottery" && action === "status") return await client.status();
|
||||
if (group === "lottery" && action === "draw") return parsed.confirm ? await client.draw() : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" };
|
||||
if (group === "lottery" && action === "reset") {
|
||||
@@ -117,105 +162,68 @@ async function remote(parsed: Parsed, config: ReturnType<typeof loadConfig>, tar
|
||||
}
|
||||
if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm);
|
||||
if (group === "api" && action === "smoke") {
|
||||
const [status, scores, ranking, lottery] = await Promise.all([
|
||||
client.serviceStatus(),
|
||||
client.scores(),
|
||||
client.ranking(),
|
||||
client.lottery(),
|
||||
]);
|
||||
const [status, scores, ranking, lottery] = await Promise.all([client.serviceStatus(), client.scores(), client.ranking(), client.lottery()]);
|
||||
const refreshed = await client.refreshScores();
|
||||
return {
|
||||
ok: true,
|
||||
ok: status.ok === true && scores.ok === true && refreshed.ok === true && ranking.ok === true && lottery.ok === true,
|
||||
action: "apistate-api-smoke",
|
||||
checks: {
|
||||
status: status.ok === true,
|
||||
scores: scores.ok === true,
|
||||
refresh: refreshed.ok === true && refreshed.snapshotOk === true,
|
||||
ranking: ranking.ok === true,
|
||||
lottery: lottery.ok === true,
|
||||
},
|
||||
checks: { status: status.ok === true, scores: scores.ok === true, refresh: refreshed.ok === true && refreshed.snapshotOk === true, ranking: ranking.ok === true, lottery: lottery.ok === true },
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
throw new Error(`unknown command: ${parsed.command.join(" ")}`);
|
||||
}
|
||||
|
||||
function aggregateSmoke(): Record<string, unknown> {
|
||||
const account = (groupId: number, groupName: string, successRequests: number, failureRequests: number, ttftP95Ms: number, apiAmountUsd: number) => ({
|
||||
accountId: 15, accountName: "lyon9801 0.0", groupId, groupName, status: "active", currentlyAvailable: true, priority: 1,
|
||||
successRequests, failureRequests, observedAttempts: successRequests + failureRequests, streamSuccessRequests: successRequests,
|
||||
firstTokenSamples: successRequests, ttftP95Ms, usage: { requestCount: successRequests, tokenCount: successRequests * 1000, apiAmountUsd },
|
||||
});
|
||||
const rows = mergeAccountScores([account(3, "自用", 100, 0, 10_591, 1), account(2, "unidesk-codex-pool", 50, 1, 13_206, 0.5)]);
|
||||
const row = rows[0] ?? {};
|
||||
const checks = {
|
||||
uniqueAccount: rows.length === 1,
|
||||
groupsMerged: Array.isArray(row.groupNames) && row.groupNames.length === 2,
|
||||
attemptsMerged: row.observedAttempts === 151,
|
||||
usageMerged: record(row.usage)?.requestCount === 150 && record(row.usage)?.apiAmountUsd === 1.5,
|
||||
decimalScore: typeof row.score === "number" && !Number.isInteger(row.score),
|
||||
conservativeTtft: row.ttftP95Ms === 13_206,
|
||||
};
|
||||
return { ok: Object.values(checks).every(Boolean), action: "account-score-aggregate-smoke", checks, mutation: false };
|
||||
}
|
||||
|
||||
export async function runCli(args: string[]): Promise<void> {
|
||||
const wantsJson = args.includes("--json");
|
||||
try {
|
||||
if (args.includes("--help") || args.length === 0) {
|
||||
console.log(JSON.stringify(help(), null, 2));
|
||||
return;
|
||||
}
|
||||
if (args.includes("--help") || args.length === 0) return emit(help(), wantsJson);
|
||||
const parsed = parseArgs(args);
|
||||
const config = loadConfig(parsed.configPath);
|
||||
if (parsed.command.join(" ") === "config validate") {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
configPath: config.configPath,
|
||||
kind: config.kind,
|
||||
service: config.metadata.name,
|
||||
refreshIntervalMinutes: config.monitor.refreshIntervalMinutes,
|
||||
scoreWindow: config.monitor.scoreWindow,
|
||||
automaticCreditEnabled: config.lottery.automaticCredit.enabled,
|
||||
excludedIdentities: config.lottery.eligibility.excludedIdentities,
|
||||
valuesPrinted: false,
|
||||
}, null, 2));
|
||||
return;
|
||||
if (parsed.command.join(" ") === "config validate") return emit({
|
||||
ok: true, configPath: config.configPath, kind: config.kind, service: config.metadata.name,
|
||||
temporalNamespace: config.temporal.namespace, temporalTaskQueue: config.temporal.taskQueue,
|
||||
refreshIntervalMinutes: config.monitor.refreshIntervalMinutes, scoreWindow: config.monitor.scoreWindow,
|
||||
automaticCreditEnabled: config.lottery.automaticCredit.enabled, valuesPrinted: false,
|
||||
}, parsed.json);
|
||||
if (parsed.command.join(" ") === "scores aggregate-smoke") return emit(aggregateSmoke(), parsed.json);
|
||||
if (parsed.command[0] === "native") {
|
||||
const action = parsed.command[1];
|
||||
if (!parsed.component) throw new Error("native commands require --component api, worker, or web");
|
||||
const result = action === "start" ? nativeStart(config, parsed.component)
|
||||
: action === "stop" ? nativeStop(config, parsed.component)
|
||||
: action === "status" ? nativeStatus(config, parsed.component)
|
||||
: action === "logs" ? nativeLogs(config, parsed.component, parsed.tail ?? 40)
|
||||
: (() => { throw new Error(`unknown native action ${action ?? ""}`); })();
|
||||
return emit(result, parsed.json);
|
||||
}
|
||||
if (parsed.command.join(" ") === "scores aggregate-smoke") {
|
||||
const account = (groupId: number, groupName: string, successRequests: number, failureRequests: number, ttftP95Ms: number, apiAmountUsd: number) => ({
|
||||
accountId: 15,
|
||||
accountName: "lyon9801 0.0",
|
||||
groupId,
|
||||
groupName,
|
||||
status: "active",
|
||||
currentlyAvailable: true,
|
||||
priority: 1,
|
||||
successRequests,
|
||||
failureRequests,
|
||||
observedAttempts: successRequests + failureRequests,
|
||||
streamSuccessRequests: successRequests,
|
||||
firstTokenSamples: successRequests,
|
||||
ttftP95Ms,
|
||||
usage: { requestCount: successRequests, tokenCount: successRequests * 1000, apiAmountUsd },
|
||||
});
|
||||
const rows = mergeAccountScores([
|
||||
account(3, "自用", 100, 0, 10_591, 1),
|
||||
account(2, "unidesk-codex-pool", 50, 1, 13_206, 0.5),
|
||||
]);
|
||||
const row = rows[0] ?? {};
|
||||
const checks = {
|
||||
uniqueAccount: rows.length === 1,
|
||||
groupsMerged: Array.isArray(row.groupNames) && row.groupNames.length === 2,
|
||||
attemptsMerged: row.observedAttempts === 151,
|
||||
usageMerged: record(row.usage)?.requestCount === 150 && record(row.usage)?.apiAmountUsd === 1.5,
|
||||
decimalScore: typeof row.score === "number" && !Number.isInteger(row.score),
|
||||
conservativeTtft: row.ttftP95Ms === 13_206,
|
||||
};
|
||||
console.log(JSON.stringify({
|
||||
ok: Object.values(checks).every(Boolean),
|
||||
action: "account-score-aggregate-smoke",
|
||||
checks,
|
||||
result: {
|
||||
accountId: row.accountId,
|
||||
accountName: row.accountName,
|
||||
groupNames: row.groupNames,
|
||||
observedAttempts: row.observedAttempts,
|
||||
score: row.score,
|
||||
ttftP95Ms: row.ttftP95Ms,
|
||||
usage: row.usage,
|
||||
},
|
||||
mutation: false,
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
const targetId = parsed.targetId ?? config.runtime.defaultCliTarget;
|
||||
const targetId = parsed.targetId ?? (parsed.overApi ? config.runtime.overApiTarget : config.runtime.defaultCliTarget);
|
||||
const target = config.runtime.cliTargets[targetId];
|
||||
if (!target) throw new Error(`runtime.cliTargets.${targetId} does not exist`);
|
||||
if (parsed.overApi && target.mode !== "http") throw new Error(`--over-api requires an http target; ${targetId} is ${target.mode}`);
|
||||
const result = target.mode === "embedded" ? await embedded(parsed, config, target) : await remote(parsed, config, target);
|
||||
console.log(JSON.stringify({ target: targetId, ...result as Record<string, unknown> }, null, 2));
|
||||
emit({ target: targetId, transport: target.mode === "embedded" ? "local-dispatcher" : "http", ...result as Record<string, unknown> }, parsed.json);
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
||||
emit({ ok: false, error: error instanceof Error ? error.message : String(error), valuesPrinted: false }, wantsJson);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user