feat: 建立 ApiState Temporal native 开发链

This commit is contained in:
root
2026-07-17 03:15:50 +02:00
parent ac695021e9
commit d203b22183
19 changed files with 1099 additions and 172 deletions
+116 -108
View File
@@ -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;
}
}