fix: aggregate Sub2API scores on NC01
This commit is contained in:
@@ -98,6 +98,7 @@ runtime:
|
|||||||
databasePath: .state/sub2rank.sqlite
|
databasePath: .state/sub2rank.sqlite
|
||||||
scoreCachePath: .state/account-scores.json
|
scoreCachePath: .state/account-scores.json
|
||||||
monitorWorkDir: /root/unidesk
|
monitorWorkDir: /root/unidesk
|
||||||
|
temporalTaskQueue: apistate-native
|
||||||
production:
|
production:
|
||||||
mode: http
|
mode: http
|
||||||
baseUrl: https://apistate.hwpod.com
|
baseUrl: https://apistate.hwpod.com
|
||||||
@@ -171,6 +172,8 @@ runtime:
|
|||||||
databasePath: .state/sub2rank.sqlite
|
databasePath: .state/sub2rank.sqlite
|
||||||
scoreCachePath: .state/account-scores.json
|
scoreCachePath: .state/account-scores.json
|
||||||
monitorWorkDir: /root/unidesk
|
monitorWorkDir: /root/unidesk
|
||||||
|
temporalTaskQueue: apistate-native
|
||||||
|
scoreScheduleWorkflowId: apistate-native-score-refresh-schedule
|
||||||
adminTokenEnv: APISTATE_API_KEY
|
adminTokenEnv: APISTATE_API_KEY
|
||||||
sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL
|
sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL
|
||||||
sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD
|
sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD
|
||||||
@@ -189,6 +192,8 @@ runtime:
|
|||||||
databasePath: /var/lib/sub2rank/sub2rank.sqlite
|
databasePath: /var/lib/sub2rank/sub2rank.sqlite
|
||||||
scoreCachePath: /var/lib/sub2rank/account-scores.json
|
scoreCachePath: /var/lib/sub2rank/account-scores.json
|
||||||
monitorWorkDir: /opt/unidesk
|
monitorWorkDir: /opt/unidesk
|
||||||
|
temporalTaskQueue: apistate
|
||||||
|
scoreScheduleWorkflowId: apistate-score-refresh-schedule
|
||||||
adminTokenEnv: APISTATE_API_KEY
|
adminTokenEnv: APISTATE_API_KEY
|
||||||
sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL
|
sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL
|
||||||
sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD
|
sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD
|
||||||
|
|||||||
+12
-5
@@ -160,7 +160,7 @@ function isAppCommand(value: AppCommand | Record<string, unknown>): value is App
|
|||||||
|
|
||||||
async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: EmbeddedCliTarget): Promise<unknown> {
|
async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: EmbeddedCliTarget): Promise<unknown> {
|
||||||
if (parsed.command.join(" ") === "scores refresh" || parsed.command.join(" ") === "workflow status") {
|
if (parsed.command.join(" ") === "scores refresh" || parsed.command.join(" ") === "workflow status") {
|
||||||
const temporal = await TemporalGateway.connect(config);
|
const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue });
|
||||||
try {
|
try {
|
||||||
if (parsed.command[0] === "scores") return await temporal.submit({ kind: "scores.refresh" });
|
if (parsed.command[0] === "scores") return await temporal.submit({ kind: "scores.refresh" });
|
||||||
if (!parsed.id) throw new Error("workflow status requires --id");
|
if (!parsed.id) throw new Error("workflow status requires --id");
|
||||||
@@ -174,7 +174,7 @@ async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, t
|
|||||||
const context = createEmbeddedContext(config, target);
|
const context = createEmbeddedContext(config, target);
|
||||||
let temporal: TemporalGateway | null = null;
|
let temporal: TemporalGateway | null = null;
|
||||||
try {
|
try {
|
||||||
if (usesWorkflow(command)) temporal = await TemporalGateway.connect(config);
|
if (usesWorkflow(command)) temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue });
|
||||||
return await new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal).dispatch(command);
|
return await new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal).dispatch(command);
|
||||||
} finally {
|
} finally {
|
||||||
if (temporal) await temporal.close();
|
if (temporal) await temporal.close();
|
||||||
@@ -206,11 +206,15 @@ async function remote(parsed: Parsed, config: ReturnType<typeof loadConfig>, tar
|
|||||||
if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm);
|
if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm);
|
||||||
if (group === "api" && action === "smoke") {
|
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();
|
const refreshed = await client.workflowSubmit({ kind: "scores.refresh" });
|
||||||
return {
|
return {
|
||||||
ok: status.ok === true && scores.ok === true && refreshed.ok === true && ranking.ok === true && lottery.ok === true,
|
ok: status.ok === true && scores.ok === true && refreshed.ok === true && ranking.ok === true && lottery.ok === true,
|
||||||
action: "apistate-api-smoke",
|
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, refreshSubmitted: refreshed.ok === true, ranking: ranking.ok === true, lottery: lottery.ok === true },
|
||||||
|
workflowId: refreshed.workflowId,
|
||||||
|
runId: refreshed.runId,
|
||||||
|
state: refreshed.state,
|
||||||
|
next: "workflow status --id <workflowId>",
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -244,7 +248,10 @@ export async function runCli(args: string[]): Promise<void> {
|
|||||||
const config = loadConfig(parsed.configPath);
|
const config = loadConfig(parsed.configPath);
|
||||||
if (parsed.command.join(" ") === "config validate") return emit({
|
if (parsed.command.join(" ") === "config validate") return emit({
|
||||||
ok: true, configPath: config.configPath, kind: config.kind, service: config.metadata.name,
|
ok: true, configPath: config.configPath, kind: config.kind, service: config.metadata.name,
|
||||||
temporalNamespace: config.temporal.namespace, temporalTaskQueue: config.temporal.taskQueue,
|
temporalNamespace: config.temporal.namespace,
|
||||||
|
temporalTaskQueue: config.temporal.taskQueue,
|
||||||
|
cliTargets: Object.fromEntries(Object.entries(config.runtime.cliTargets).map(([id, target]) => [id, target.mode === "embedded" ? { mode: target.mode, temporalTaskQueue: target.temporalTaskQueue } : { mode: target.mode }])),
|
||||||
|
serverTargets: Object.fromEntries(Object.entries(config.runtime.serverTargets).map(([id, target]) => [id, { temporalTaskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId }])),
|
||||||
refreshIntervalMinutes: config.monitor.refreshIntervalMinutes, scoreWindow: config.monitor.scoreWindow,
|
refreshIntervalMinutes: config.monitor.refreshIntervalMinutes, scoreWindow: config.monitor.scoreWindow,
|
||||||
automaticCreditEnabled: config.lottery.automaticCredit.enabled, valuesPrinted: false,
|
automaticCreditEnabled: config.lottery.automaticCredit.enabled, valuesPrinted: false,
|
||||||
}, parsed.json);
|
}, parsed.json);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { aggregateNativeGroupScore, collectNativeScores } from "./account-score-native";
|
||||||
|
import type { Sub2ApiClient } from "./sub2api-client";
|
||||||
|
|
||||||
|
const availableOps = { status: "available" as const, data: {}, reason: null };
|
||||||
|
|
||||||
|
describe("aggregateNativeGroupScore", () => {
|
||||||
|
test("scores upstream failures and excludes client input locally", () => {
|
||||||
|
const group = { id: 2, name: "pool", platform: "openai", status: "active" };
|
||||||
|
const account = { id: 15, name: "primary 0.02", platform: "openai", status: "active", schedulable: true, priority: 1 };
|
||||||
|
const usage = Array.from({ length: 20 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
account_id: 15,
|
||||||
|
group_id: 2,
|
||||||
|
model: "gpt-test",
|
||||||
|
stream: true,
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 20,
|
||||||
|
actual_cost: 0.1,
|
||||||
|
duration_ms: 20_000,
|
||||||
|
first_token_ms: 5_000 + index,
|
||||||
|
created_at: "2026-07-17T09:00:00Z",
|
||||||
|
}));
|
||||||
|
const result = aggregateNativeGroupScore({
|
||||||
|
group,
|
||||||
|
accounts: [account],
|
||||||
|
usage,
|
||||||
|
requestErrors: [
|
||||||
|
{ id: 1, request_id: "req-fail", account_id: 15, status_code: 502, phase: "upstream", type: "upstream_error", message: "Upstream service temporarily unavailable" },
|
||||||
|
{ id: 2, request_id: "req-input", account_id: 15, status_code: 400, phase: "client", type: "invalid_request", message: "Input must be a list" },
|
||||||
|
],
|
||||||
|
systemLogs: [
|
||||||
|
{ id: 10, created_at: "2026-07-17T09:01:00Z", message: "openai.upstream_failover_switching", request_id: "req-fail", account_id: 15, extra: { group_id: 2 } },
|
||||||
|
{ id: 11, created_at: "2026-07-17T09:01:01Z", message: "http request completed", request_id: "req-fail", account_id: 15, extra: { group_id: 2, status_code: 200 } },
|
||||||
|
],
|
||||||
|
overview: {
|
||||||
|
request_count_total: 22,
|
||||||
|
error_count_total: 1,
|
||||||
|
upstream_error_count_excl_429_529: 1,
|
||||||
|
upstream_429_count: 0,
|
||||||
|
upstream_529_count: 0,
|
||||||
|
error_rate: 1 / 22,
|
||||||
|
upstream_error_rate: 1 / 22,
|
||||||
|
ttft: { p99_ms: 5019 },
|
||||||
|
},
|
||||||
|
availability: availableOps,
|
||||||
|
concurrency: availableOps,
|
||||||
|
});
|
||||||
|
const row = result.accounts[0]!;
|
||||||
|
expect(row.successRequests).toBe(20);
|
||||||
|
expect(row.failureRequests).toBe(1);
|
||||||
|
expect(row.scoreableUpstreamErrorRequests).toBe(1);
|
||||||
|
expect(row.excludedNonUpstreamErrorRequests).toBe(1);
|
||||||
|
expect(row.failoverRequests).toBe(1);
|
||||||
|
expect(row.failoverRecovered).toBe(1);
|
||||||
|
expect((row.usage as Record<string, unknown>).tokenCount).toBe(2400);
|
||||||
|
expect((row.usage as Record<string, unknown>).upstreamCostCny).toBe(0.04);
|
||||||
|
expect(result.collection.mode).toBe("nc01-native-api-local-aggregation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not attribute another group policy event", () => {
|
||||||
|
const result = aggregateNativeGroupScore({
|
||||||
|
group: { id: 2, name: "pool", platform: "openai", status: "active" },
|
||||||
|
accounts: [{ id: 15, name: "primary 0.0", platform: "openai", status: "active", schedulable: true, priority: 1 }],
|
||||||
|
usage: [],
|
||||||
|
requestErrors: [],
|
||||||
|
systemLogs: [{ id: 1, created_at: "2026-07-17T09:00:00Z", message: "openai.forward_failed", request_id: "req-other", account_id: 15, extra: { group_id: 3 } }],
|
||||||
|
overview: {},
|
||||||
|
availability: {
|
||||||
|
status: "available",
|
||||||
|
data: {
|
||||||
|
group: { "2": { total_accounts: 1, available_count: 0, rate_limit_count: 0, error_count: 1 } },
|
||||||
|
account: { "15": { account_id: 15, group_id: 2, is_available: false } },
|
||||||
|
},
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
concurrency: { status: "available", data: { group: { "2": { current_in_use: 0, max_capacity: 10, waiting_in_queue: 0 } } }, reason: null },
|
||||||
|
});
|
||||||
|
expect(result.accounts[0]!.forwardFailedRequests).toBe(0);
|
||||||
|
expect(result.accounts[0]!.currentlyAvailable).toBe(false);
|
||||||
|
expect(result.group.unavailableAccountCount).toBe(1);
|
||||||
|
expect(result.group.maxCapacity).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collects groups sequentially and deduplicates marker results", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const fake = {
|
||||||
|
async listGroups() {
|
||||||
|
calls.push("groups");
|
||||||
|
return [
|
||||||
|
{ id: 2, name: "pool", platform: "openai", status: "active" },
|
||||||
|
{ id: 3, name: "self", platform: "openai", status: "active" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async listGroupAccounts(groupId: number) { calls.push(`accounts:${groupId}`); return []; },
|
||||||
|
async getOpsOverview(groupId: number) { calls.push(`overview:${groupId}`); return {}; },
|
||||||
|
async getOpsAccountAvailability(groupId: number) { calls.push(`availability:${groupId}`); return {}; },
|
||||||
|
async getOpsConcurrency(groupId: number) { calls.push(`concurrency:${groupId}`); return {}; },
|
||||||
|
async listGroupUsage(groupId: number) { calls.push(`usage:${groupId}`); return []; },
|
||||||
|
async listRequestErrors(groupId: number) { calls.push(`errors:${groupId}`); return []; },
|
||||||
|
} as unknown as Sub2ApiClient;
|
||||||
|
const events = {
|
||||||
|
async collect() {
|
||||||
|
calls.push("events");
|
||||||
|
return {
|
||||||
|
events: [{ id: 1, created_at: "2026-07-17T09:00:00Z", message: "account_temp_unschedulable", account_id: 15 }],
|
||||||
|
evidence: { source: "test", eventCount: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await collectNativeScores(fake, events, "8h", new Date("2026-07-17T10:00:00Z"));
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(calls.filter((call) => call === "events")).toHaveLength(1);
|
||||||
|
expect(calls.indexOf("accounts:3")).toBeGreaterThan(calls.indexOf("errors:2"));
|
||||||
|
const collection = result.collection.groups as Array<Record<string, unknown>>;
|
||||||
|
expect(collection[0]!.policyEventRows).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import type {
|
||||||
|
Sub2ApiAccount,
|
||||||
|
Sub2ApiGroup,
|
||||||
|
Sub2ApiRequestError,
|
||||||
|
Sub2ApiSystemLog,
|
||||||
|
Sub2ApiUsageRow,
|
||||||
|
} from "./sub2api-client";
|
||||||
|
import { Sub2ApiClient } from "./sub2api-client";
|
||||||
|
import type { RuntimePolicyEventSource } from "./runtime-policy-events";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface NativeGroupScoreInput {
|
||||||
|
group: Sub2ApiGroup;
|
||||||
|
accounts: Sub2ApiAccount[];
|
||||||
|
usage: Sub2ApiUsageRow[];
|
||||||
|
requestErrors: Sub2ApiRequestError[];
|
||||||
|
systemLogs: Sub2ApiSystemLog[];
|
||||||
|
overview: Record<string, unknown>;
|
||||||
|
availability: NativeOpsResult;
|
||||||
|
concurrency: NativeOpsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeOpsResult {
|
||||||
|
status: "available" | "unavailable";
|
||||||
|
data: Row;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyMarkers = {
|
||||||
|
temp: "account_temp_unschedulable",
|
||||||
|
failover: "openai.upstream_failover_switching",
|
||||||
|
forward: "openai.forward_failed",
|
||||||
|
retryOpenAI: "openai.pool_mode_same_account_retry",
|
||||||
|
retryGateway: "gateway.failover_same_account_retry",
|
||||||
|
completed: "http request completed",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const nativeScoreSystemLogMarkers = Object.values(policyMarkers);
|
||||||
|
|
||||||
|
function windowStart(window: string, now: Date): Date {
|
||||||
|
const match = window.match(/^([1-9][0-9]*)(m|h|d)$/u);
|
||||||
|
if (!match) throw new Error(`unsupported score window: ${window}`);
|
||||||
|
const amount = Number(match[1]);
|
||||||
|
const unitMs = match[2] === "m" ? 60_000 : match[2] === "h" ? 3_600_000 : 86_400_000;
|
||||||
|
return new Date(now.getTime() - amount * unitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectNativeScores(client: Sub2ApiClient, eventSource: RuntimePolicyEventSource, window: string, now = new Date()): Promise<{ groups: Row[]; accounts: Row[]; collection: Row }> {
|
||||||
|
const start = windowStart(window, now);
|
||||||
|
const groups = await client.listGroups();
|
||||||
|
const policyEvents = await eventSource.collect(window);
|
||||||
|
const groupRows: Row[] = [];
|
||||||
|
const accountRows: Row[] = [];
|
||||||
|
const collectionRows: Row[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
// Keep database-heavy reads sequential on the two-core Sub2API host.
|
||||||
|
const accounts = await client.listGroupAccounts(group.id, group.platform);
|
||||||
|
const overview = await client.getOpsOverview(group.id, group.platform, start);
|
||||||
|
const availability = await optionalOps(() => client.getOpsAccountAvailability(group.id, group.platform));
|
||||||
|
const concurrency = await optionalOps(() => client.getOpsConcurrency(group.id, group.platform));
|
||||||
|
const usage = await client.listGroupUsage(group.id, start, now);
|
||||||
|
const requestErrors = await client.listRequestErrors(group.id, group.platform, start);
|
||||||
|
const result = aggregateNativeGroupScore({ group, accounts, usage, requestErrors, systemLogs: policyEvents.events, overview, availability, concurrency });
|
||||||
|
groupRows.push(result.group);
|
||||||
|
accountRows.push(...result.accounts.map((account) => ({ groupId: group.id, groupName: group.name, platform: group.platform, ...account })));
|
||||||
|
collectionRows.push({ groupId: group.id, groupName: group.name, ...result.collection });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groups: groupRows,
|
||||||
|
accounts: accountRows,
|
||||||
|
collection: {
|
||||||
|
mode: "nc01-native-api-local-aggregation",
|
||||||
|
window,
|
||||||
|
startAt: start.toISOString(),
|
||||||
|
endAt: now.toISOString(),
|
||||||
|
groupCount: groups.length,
|
||||||
|
groups: collectionRows,
|
||||||
|
policyEvents: policyEvents.evidence,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalOps(operation: () => Promise<Row>): Promise<NativeOpsResult> {
|
||||||
|
try {
|
||||||
|
return { status: "available", data: await operation(), reason: null };
|
||||||
|
} catch (error) {
|
||||||
|
return { status: "unavailable", data: {}, reason: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numeric(value: unknown): number | null {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return value !== null && value !== undefined && Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Row {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(values: number[], fraction: number): number | null {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
const ordered = [...values].sort((left, right) => left - right);
|
||||||
|
const position = (ordered.length - 1) * fraction;
|
||||||
|
const lower = Math.floor(position);
|
||||||
|
const upper = Math.min(lower + 1, ordered.length - 1);
|
||||||
|
return Math.round(ordered[lower]! + (ordered[upper]! - ordered[lower]!) * (position - lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
function costRate(name: string): number | null {
|
||||||
|
const match = name.match(/(\d+(?:\.\d+)?)$/u);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extra(log: Sub2ApiSystemLog): Row {
|
||||||
|
return log.extra && typeof log.extra === "object" ? log.extra : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountId(log: Sub2ApiSystemLog): number | null {
|
||||||
|
return numeric(log.account_id) ?? numeric(extra(log).account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestId(log: Sub2ApiSystemLog): string | null {
|
||||||
|
const value = log.request_id ?? extra(log).request_id;
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMatches(log: Sub2ApiSystemLog, groupId: number): boolean {
|
||||||
|
const value = numeric(extra(log).group_id);
|
||||||
|
return value === null || value === groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerErrorAttribution(row: Sub2ApiRequestError): { scoreable: boolean; reason: string } {
|
||||||
|
const message = String(row.message ?? row.error_message ?? "").toLowerCase();
|
||||||
|
if (message.includes("context window") || message.includes("context_length_exceeded")) return { scoreable: false, reason: "context-window" };
|
||||||
|
if (message.includes("input must be a list")) return { scoreable: false, reason: "invalid-client-input" };
|
||||||
|
if (message.includes("not supported by any configured account") || message.includes("no available channel for model")) return { scoreable: false, reason: "model-route" };
|
||||||
|
const phase = String(row.phase ?? "").toLowerCase();
|
||||||
|
if (["internal", "client", "business"].includes(phase)) return { scoreable: false, reason: "non-upstream-phase" };
|
||||||
|
if (row.account_id === null || row.account_id === undefined) return { scoreable: false, reason: "no-account-attribution" };
|
||||||
|
const category = String(row.type ?? "").toLowerCase();
|
||||||
|
if (phase === "upstream" || category.includes("upstream")) return { scoreable: true, reason: "explicit-upstream" };
|
||||||
|
const stable = ["upstream service temporarily unavailable", "upstream request failed", "bad gateway", "gateway timeout", "error code: 502", "error code: 503", "error code: 504", "error code: 524"];
|
||||||
|
return stable.some((marker) => message.includes(marker))
|
||||||
|
? { scoreable: true, reason: "stable-upstream-message" }
|
||||||
|
: { scoreable: false, reason: "unattributed-customer-error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function grade(score: number | null, comparable: boolean, attempts: number): string {
|
||||||
|
if (score === null || (!comparable && !(score < 60 && attempts >= 10))) return "insufficient";
|
||||||
|
return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "E";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateNativeGroupScore(input: NativeGroupScoreInput): { group: Row; accounts: Row[]; collection: Row } {
|
||||||
|
const usageByAccount = new Map<number, Sub2ApiUsageRow[]>();
|
||||||
|
for (const row of input.usage) {
|
||||||
|
if (row.account_id === null) continue;
|
||||||
|
usageByAccount.set(row.account_id, [...(usageByAccount.get(row.account_id) ?? []), row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreableByAccount = new Map<number, Set<string>>();
|
||||||
|
const excludedByAccount = new Map<number, Map<string, number>>();
|
||||||
|
const customerErrorCount = new Map<number, Set<string>>();
|
||||||
|
for (const row of input.requestErrors) {
|
||||||
|
if (row.account_id === null || row.account_id === undefined || !row.request_id) continue;
|
||||||
|
const id = row.account_id;
|
||||||
|
customerErrorCount.set(id, new Set([...(customerErrorCount.get(id) ?? []), row.request_id]));
|
||||||
|
const attribution = customerErrorAttribution(row);
|
||||||
|
if (attribution.scoreable) scoreableByAccount.set(id, new Set([...(scoreableByAccount.get(id) ?? []), row.request_id]));
|
||||||
|
else {
|
||||||
|
const reasons = excludedByAccount.get(id) ?? new Map<string, number>();
|
||||||
|
reasons.set(attribution.reason, (reasons.get(attribution.reason) ?? 0) + 1);
|
||||||
|
excludedByAccount.set(id, reasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStatus = new Map<string, number>();
|
||||||
|
const failover = new Map<number, Set<string>>();
|
||||||
|
const forward = new Map<number, Set<string>>();
|
||||||
|
const temp = new Map<number, number>();
|
||||||
|
const retries = new Map<number, number>();
|
||||||
|
const upstreamStatuses = new Map<number, Map<number, number>>();
|
||||||
|
for (const log of input.systemLogs) {
|
||||||
|
if (!groupMatches(log, input.group.id)) continue;
|
||||||
|
const id = accountId(log);
|
||||||
|
const request = requestId(log);
|
||||||
|
const message = log.message ?? "";
|
||||||
|
if (message.includes(policyMarkers.completed) && request) {
|
||||||
|
const status = numeric(extra(log).status_code);
|
||||||
|
if (status !== null) finalStatus.set(request, status);
|
||||||
|
}
|
||||||
|
if (id === null) continue;
|
||||||
|
if (message.includes(policyMarkers.temp)) temp.set(id, (temp.get(id) ?? 0) + 1);
|
||||||
|
if ((message.includes(policyMarkers.retryOpenAI) || message.includes(policyMarkers.retryGateway))) retries.set(id, (retries.get(id) ?? 0) + 1);
|
||||||
|
const eventGroupId = numeric(extra(log).group_id);
|
||||||
|
if (request && eventGroupId === input.group.id && message.includes(policyMarkers.failover)) {
|
||||||
|
failover.set(id, new Set([...(failover.get(id) ?? []), request]));
|
||||||
|
const status = numeric(extra(log).upstream_status);
|
||||||
|
if (status !== null) {
|
||||||
|
const buckets = upstreamStatuses.get(id) ?? new Map<number, number>();
|
||||||
|
buckets.set(status, (buckets.get(status) ?? 0) + 1);
|
||||||
|
upstreamStatuses.set(id, buckets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request && eventGroupId === input.group.id && message.includes(policyMarkers.forward)) forward.set(id, new Set([...(forward.get(id) ?? []), request]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityAccounts = Object.values(record(input.availability.data.account)).map(record);
|
||||||
|
const availabilityByAccount = new Map<number, Row>();
|
||||||
|
for (const value of availabilityAccounts) {
|
||||||
|
const id = numeric(value.account_id);
|
||||||
|
if (id !== null && numeric(value.group_id) === input.group.id) availabilityByAccount.set(id, value);
|
||||||
|
}
|
||||||
|
const availabilityGroups = record(input.availability.data.group);
|
||||||
|
const availabilityGroup = record(availabilityGroups[String(input.group.id)] ?? availabilityGroups[input.group.id]);
|
||||||
|
const concurrencyGroups = record(input.concurrency.data.group);
|
||||||
|
const concurrencyGroup = record(concurrencyGroups[String(input.group.id)] ?? concurrencyGroups[input.group.id]);
|
||||||
|
|
||||||
|
const accountRows = input.accounts.map((account): Row => {
|
||||||
|
const usages = usageByAccount.get(account.id) ?? [];
|
||||||
|
const streams = usages.filter((row) => row.stream);
|
||||||
|
const ttft = streams.flatMap((row) => row.first_token_ms === null ? [] : [row.first_token_ms]);
|
||||||
|
const durations = usages.flatMap((row) => row.duration_ms === null ? [] : [row.duration_ms]);
|
||||||
|
const scoreable = scoreableByAccount.get(account.id) ?? new Set<string>();
|
||||||
|
const failed = new Set([...(failover.get(account.id) ?? []), ...(forward.get(account.id) ?? []), ...scoreable]);
|
||||||
|
const successRequests = usages.length;
|
||||||
|
const failureRequests = failed.size;
|
||||||
|
const attempts = successRequests + failureRequests;
|
||||||
|
const failureRate = attempts > 0 ? Math.round(failureRequests / attempts * 1_000_000) / 1_000_000 : null;
|
||||||
|
const ttftP95Ms = percentile(ttft, 0.95);
|
||||||
|
const reliability = failureRate === null ? null : Math.round(60 * (1 - Math.min(Math.max(failureRate, 0), 0.2) / 0.2) * 100) / 100;
|
||||||
|
const latency = ttft.length < 5 || ttftP95Ms === null ? null : Math.round(25 * (1 - Math.min(Math.max(ttftP95Ms - 10_000, 0), 170_000) / 170_000) * 100) / 100;
|
||||||
|
const nativeAvailability = availabilityByAccount.get(account.id)?.is_available;
|
||||||
|
const currentlyAvailable = typeof nativeAvailability === "boolean"
|
||||||
|
? nativeAvailability
|
||||||
|
: account.status === "active" && account.schedulable !== false;
|
||||||
|
const availability = currentlyAvailable ? 15 : account.status === "active" ? 8 : 0;
|
||||||
|
const availableWeight = (reliability === null ? 0 : 60) + (latency === null ? 0 : 25) + 15;
|
||||||
|
const score = attempts > 0 ? Math.round(((reliability ?? 0) + (latency ?? 0) + availability) / availableWeight * 1_000) / 10 : null;
|
||||||
|
const comparable = attempts >= 10 && ttft.length >= 5;
|
||||||
|
const accountGrade = grade(score, comparable, attempts);
|
||||||
|
const failoverIds = failover.get(account.id) ?? new Set<string>();
|
||||||
|
const failoverRecovered = [...failoverIds].filter((id) => (finalStatus.get(id) ?? 999) < 400).length;
|
||||||
|
const failoverFailed = [...failoverIds].filter((id) => (finalStatus.get(id) ?? 0) >= 400).length;
|
||||||
|
const models = new Map<string, number>();
|
||||||
|
for (const usage of usages) models.set(usage.model || "unknown", (models.get(usage.model || "unknown") ?? 0) + 1);
|
||||||
|
const amount = usages.reduce((sum, row) => sum + (numeric(row.actual_cost) ?? 0), 0);
|
||||||
|
const rate = costRate(account.name);
|
||||||
|
const reasons = [
|
||||||
|
...(failureRate !== null && failureRate >= 0.1 ? ["failure-rate>=10%"] : failureRate !== null && failureRate >= 0.03 ? ["failure-rate>=3%"] : []),
|
||||||
|
...(failoverIds.size > 0 ? ["upstream-failover-triggered"] : []),
|
||||||
|
...(!currentlyAvailable ? ["currently-unavailable"] : []),
|
||||||
|
...(ttft.length < 5 ? ["ttft-evidence-insufficient"] : []),
|
||||||
|
...(attempts < 10 ? ["request-evidence-insufficient"] : []),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
status: account.status,
|
||||||
|
schedulable: account.schedulable,
|
||||||
|
currentlyAvailable,
|
||||||
|
priority: account.priority,
|
||||||
|
priorityOrder: "lower-is-higher",
|
||||||
|
score,
|
||||||
|
grade: accountGrade,
|
||||||
|
assessment: ({ A: "preferred", B: "healthy", C: "watch", D: "degraded", E: "poor" } as Row)[accountGrade] ?? "insufficient-evidence",
|
||||||
|
confidence: attempts >= 50 && ttft.length >= 20 ? "high" : attempts >= 10 && ttft.length >= 5 ? "medium" : "low",
|
||||||
|
scoreComparable: comparable,
|
||||||
|
observedAttempts: attempts,
|
||||||
|
successRequests,
|
||||||
|
failureRequests,
|
||||||
|
failureRate,
|
||||||
|
streamSuccessRequests: streams.length,
|
||||||
|
firstTokenSamples: ttft.length,
|
||||||
|
firstTokenCoverage: streams.length > 0 ? Math.round(ttft.length / streams.length * 1_000_000) / 1_000_000 : null,
|
||||||
|
ttftP50Ms: percentile(ttft, 0.5),
|
||||||
|
ttftP95Ms,
|
||||||
|
ttftP99Ms: percentile(ttft, 0.99),
|
||||||
|
ttftMaxMs: ttft.length ? Math.max(...ttft) : null,
|
||||||
|
durationP95Ms: percentile(durations, 0.95),
|
||||||
|
modelBuckets: [...models].sort((left, right) => right[1] - left[1]).slice(0, 8).map(([model, count]) => ({ model, count })),
|
||||||
|
customerErrorRequests: customerErrorCount.get(account.id)?.size ?? 0,
|
||||||
|
scoreableUpstreamErrorRequests: scoreable.size,
|
||||||
|
excludedNonUpstreamErrorRequests: [...(excludedByAccount.get(account.id)?.values() ?? [])].reduce((sum, value) => sum + value, 0),
|
||||||
|
excludedReasonBuckets: [...(excludedByAccount.get(account.id) ?? [])].map(([reason, count]) => ({ reason, count })),
|
||||||
|
usage: {
|
||||||
|
requestCount: usages.length,
|
||||||
|
tokenCount: usages.reduce((sum, row) => sum + row.input_tokens + row.output_tokens, 0),
|
||||||
|
apiAmountUsd: Math.round(amount * 100_000_000) / 100_000_000,
|
||||||
|
costRateCnyPerApiUsd: rate,
|
||||||
|
upstreamCostCny: rate === null ? null : Math.round(amount * rate * 100_000_000) / 100_000_000,
|
||||||
|
},
|
||||||
|
failoverRequests: failoverIds.size,
|
||||||
|
failoverRecovered,
|
||||||
|
failoverFailed,
|
||||||
|
failoverOutcomeMissing: failoverIds.size - failoverRecovered - failoverFailed,
|
||||||
|
sameAccountRetryEvents: retries.get(account.id) ?? 0,
|
||||||
|
tempUnschedulableEvents: temp.get(account.id) ?? 0,
|
||||||
|
forwardFailedRequests: forward.get(account.id)?.size ?? 0,
|
||||||
|
upstreamStatusBuckets: [...(upstreamStatuses.get(account.id) ?? [])].sort((left, right) => left[0] - right[0]).map(([statusCode, count]) => ({ statusCode, count })),
|
||||||
|
scoreComponents: { reliability, latency, availability, availableWeight },
|
||||||
|
reasons,
|
||||||
|
usageStatus: "available",
|
||||||
|
usageReason: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = input.overview;
|
||||||
|
const requestCount = numeric(overview.request_count_total) ?? 0;
|
||||||
|
const errorCount = numeric(overview.error_count_total) ?? 0;
|
||||||
|
const upstreamErrorCount = [overview.upstream_error_count_excl_429_529, overview.upstream_429_count, overview.upstream_529_count]
|
||||||
|
.reduce((sum: number, value) => sum + (numeric(value) ?? 0), 0);
|
||||||
|
const ttftOverview = overview.ttft && typeof overview.ttft === "object" ? overview.ttft as Row : {};
|
||||||
|
const durationOverview = overview.duration && typeof overview.duration === "object" ? overview.duration as Row : {};
|
||||||
|
const unavailableAccountCount = numeric(availabilityGroup.total_accounts) !== null && numeric(availabilityGroup.available_count) !== null
|
||||||
|
? Math.max(0, Number(availabilityGroup.total_accounts) - Number(availabilityGroup.available_count))
|
||||||
|
: [...availabilityByAccount.values()].filter((value) => value.is_available === false).length;
|
||||||
|
return {
|
||||||
|
group: {
|
||||||
|
groupId: input.group.id,
|
||||||
|
groupName: input.group.name,
|
||||||
|
platform: input.group.platform,
|
||||||
|
status: input.group.status,
|
||||||
|
requestCount,
|
||||||
|
errorCount,
|
||||||
|
errorRate: numeric(overview.error_rate),
|
||||||
|
upstreamErrorCount,
|
||||||
|
upstreamErrorRate: numeric(overview.upstream_error_rate),
|
||||||
|
businessLimitedCount: numeric(overview.business_limited_count) ?? 0,
|
||||||
|
ttftP99Ms: numeric(ttftOverview.p99_ms),
|
||||||
|
durationP99Ms: numeric(durationOverview.p99_ms),
|
||||||
|
totalAccounts: numeric(availabilityGroup.total_accounts),
|
||||||
|
availableCount: numeric(availabilityGroup.available_count),
|
||||||
|
rateLimitCount: numeric(availabilityGroup.rate_limit_count),
|
||||||
|
errorAccountCount: numeric(availabilityGroup.error_count),
|
||||||
|
unavailableAccountCount,
|
||||||
|
currentInUse: numeric(concurrencyGroup.current_in_use),
|
||||||
|
maxCapacity: numeric(concurrencyGroup.max_capacity),
|
||||||
|
waitingInQueue: numeric(concurrencyGroup.waiting_in_queue),
|
||||||
|
needsAttention: errorCount > 0 || upstreamErrorCount > 0 || unavailableAccountCount > 0,
|
||||||
|
opsStatus: {
|
||||||
|
overview: "available",
|
||||||
|
accountAvailability: input.availability.status,
|
||||||
|
concurrency: input.concurrency.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accounts: accountRows,
|
||||||
|
collection: {
|
||||||
|
mode: "nc01-native-api-local-aggregation",
|
||||||
|
accountCount: input.accounts.length,
|
||||||
|
usageRows: input.usage.length,
|
||||||
|
requestErrorRows: input.requestErrors.length,
|
||||||
|
policyEventRows: input.systemLogs.length,
|
||||||
|
accountAvailabilityStatus: input.availability.status,
|
||||||
|
concurrencyStatus: input.concurrency.status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { mergeAccountScores } from "./account-score-aggregation";
|
import { mergeAccountScores } from "./account-score-aggregation";
|
||||||
|
import { collectNativeScores } from "./account-score-native";
|
||||||
import type { AppConfig } from "./config";
|
import type { AppConfig } from "./config";
|
||||||
|
import type { Sub2ApiClient } from "./sub2api-client";
|
||||||
|
import type { RuntimePolicyEventSource } from "./runtime-policy-events";
|
||||||
|
|
||||||
interface ScoreSnapshot {
|
interface ScoreSnapshot {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -14,6 +17,7 @@ interface ScoreSnapshot {
|
|||||||
accounts: Array<Record<string, unknown>>;
|
accounts: Array<Record<string, unknown>>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
collection?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> | null {
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
@@ -24,31 +28,17 @@ function records(value: unknown): Array<Record<string, unknown>> {
|
|||||||
return Array.isArray(value) ? value.map(record).filter((item): item is Record<string, unknown> => item !== null) : [];
|
return Array.isArray(value) ? value.map(record).filter((item): item is Record<string, unknown> => item !== null) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function nested(root: unknown, ...keys: string[]): unknown {
|
|
||||||
let value = root;
|
|
||||||
for (const key of keys) value = record(value)?.[key];
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pooled<T, R>(values: T[], concurrency: number, operation: (value: T) => Promise<R>): Promise<R[]> {
|
|
||||||
const results = new Array<R>(values.length);
|
|
||||||
let cursor = 0;
|
|
||||||
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
||||||
for (;;) {
|
|
||||||
const index = cursor++;
|
|
||||||
if (index >= values.length) return;
|
|
||||||
results[index] = await operation(values[index]!);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AccountScoreService {
|
export class AccountScoreService {
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private inFlight: Promise<ScoreSnapshot> | null = null;
|
private inFlight: Promise<ScoreSnapshot> | null = null;
|
||||||
private snapshot: ScoreSnapshot;
|
private snapshot: ScoreSnapshot;
|
||||||
|
|
||||||
constructor(private readonly config: AppConfig, private readonly cachePath: string, private readonly cliWorkDir: string) {
|
constructor(
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
private readonly cachePath: string,
|
||||||
|
private readonly sub2api: Sub2ApiClient,
|
||||||
|
private readonly policyEvents: RuntimePolicyEventSource,
|
||||||
|
) {
|
||||||
this.snapshot = this.readCache();
|
this.snapshot = this.readCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,24 +76,9 @@ export class AccountScoreService {
|
|||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
this.snapshot = { ...this.snapshot, status: "refreshing", refreshStartedAt: startedAt.toISOString(), error: null };
|
this.snapshot = { ...this.snapshot, status: "refreshing", refreshStartedAt: startedAt.toISOString(), error: null };
|
||||||
try {
|
try {
|
||||||
const overview = await this.invoke(["--all-groups"]);
|
const collected = await collectNativeScores(this.sub2api, this.policyEvents, this.config.monitor.scoreWindow);
|
||||||
const groups = records(nested(overview, "data", "parsed", "allGroups", "groups"));
|
|
||||||
const details = await pooled(groups, 2, async (group) => {
|
|
||||||
const id = String(group.groupId ?? "");
|
|
||||||
if (!id) return { group, accounts: [] as Array<Record<string, unknown>> };
|
|
||||||
const payload = await this.invoke(["--group", id]);
|
|
||||||
return {
|
|
||||||
group,
|
|
||||||
accounts: records(nested(payload, "data", "parsed", "errors", "nativeOps", "accountQuality", "accounts")),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const refreshedAt = new Date();
|
const refreshedAt = new Date();
|
||||||
const accounts = mergeAccountScores(details.flatMap(({ group, accounts }) => accounts.map((account): Record<string, unknown> => ({
|
const accounts = mergeAccountScores(collected.accounts);
|
||||||
groupId: group.groupId ?? null,
|
|
||||||
groupName: group.groupName ?? null,
|
|
||||||
platform: group.platform ?? null,
|
|
||||||
...account,
|
|
||||||
}))));
|
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
@@ -111,10 +86,11 @@ export class AccountScoreService {
|
|||||||
refreshStartedAt: startedAt.toISOString(),
|
refreshStartedAt: startedAt.toISOString(),
|
||||||
nextRefreshAt: new Date(refreshedAt.getTime() + this.config.monitor.refreshIntervalMinutes * 60_000).toISOString(),
|
nextRefreshAt: new Date(refreshedAt.getTime() + this.config.monitor.refreshIntervalMinutes * 60_000).toISOString(),
|
||||||
window: this.config.monitor.scoreWindow,
|
window: this.config.monitor.scoreWindow,
|
||||||
groups,
|
groups: collected.groups,
|
||||||
accounts,
|
accounts,
|
||||||
error: null,
|
error: null,
|
||||||
source: "unidesk-sub2api-runtime-errors",
|
source: "sub2api-native-admin-api-local-aggregation",
|
||||||
|
collection: collected.collection,
|
||||||
};
|
};
|
||||||
this.writeCache(this.snapshot);
|
this.writeCache(this.snapshot);
|
||||||
return this.snapshot;
|
return this.snapshot;
|
||||||
@@ -131,50 +107,6 @@ export class AccountScoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async invoke(scope: string[]): Promise<Record<string, unknown>> {
|
|
||||||
const args = [
|
|
||||||
resolve(this.cliWorkDir, this.config.monitor.cli.entrypoint),
|
|
||||||
"platform-infra", "sub2api", "codex-pool", "runtime", "errors",
|
|
||||||
"--target", this.config.monitor.target,
|
|
||||||
"--since", this.config.monitor.scoreWindow,
|
|
||||||
...scope,
|
|
||||||
"--raw",
|
|
||||||
];
|
|
||||||
const process = Bun.spawn([this.config.monitor.cli.executable, ...args], {
|
|
||||||
cwd: this.cliWorkDir,
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
env: { ...Bun.env, UNIDESK_MAIN_SERVER_IP: this.config.monitor.cli.mainServerHost },
|
|
||||||
});
|
|
||||||
let timedOut = false;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
process.kill();
|
|
||||||
}, this.config.monitor.cli.timeoutMs);
|
|
||||||
const [stdout, stderr, exitCode] = await Promise.all([
|
|
||||||
new Response(process.stdout).text(),
|
|
||||||
new Response(process.stderr).text(),
|
|
||||||
process.exited,
|
|
||||||
]).finally(() => clearTimeout(timeout));
|
|
||||||
if (timedOut) throw new Error(`评分 CLI 在 ${this.config.monitor.cli.timeoutMs}ms 后超时`);
|
|
||||||
if (exitCode !== 0) throw new Error(`评分 CLI 退出码 ${exitCode}: ${stderr.trim().slice(-600)}`);
|
|
||||||
let payload = record(JSON.parse(stdout));
|
|
||||||
if (!payload || payload.ok !== true) throw new Error("评分 CLI 返回无效结果");
|
|
||||||
if (nested(payload, "data", "outputTruncated")) {
|
|
||||||
const dumpPath = nested(payload, "data", "dump", "path");
|
|
||||||
if (typeof dumpPath !== "string" || !dumpPath.startsWith("/tmp/unidesk-cli-output/")) {
|
|
||||||
throw new Error("评分 CLI 渐进披露结果缺少受保护 dump");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
payload = record(JSON.parse(readFileSync(dumpPath, "utf8")));
|
|
||||||
} finally {
|
|
||||||
try { unlinkSync(dumpPath); } catch { /* The CLI may clean up its own temporary output. */ }
|
|
||||||
}
|
|
||||||
if (!payload || payload.ok !== true) throw new Error("评分 CLI dump 返回无效结果");
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readCache(): ScoreSnapshot {
|
private readCache(): ScoreSnapshot {
|
||||||
if (existsSync(this.cachePath)) {
|
if (existsSync(this.cachePath)) {
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +126,7 @@ export class AccountScoreService {
|
|||||||
groups: [],
|
groups: [],
|
||||||
accounts: [],
|
accounts: [],
|
||||||
error: null,
|
error: null,
|
||||||
source: "unidesk-sub2api-runtime-errors",
|
source: "sub2api-native-admin-api-local-aggregation",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ export class AdminHttpClient {
|
|||||||
creditTest(execute: boolean): Promise<Record<string, unknown>> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); }
|
creditTest(execute: boolean): Promise<Record<string, unknown>> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); }
|
||||||
serviceStatus(): Promise<Record<string, unknown>> { return this.request("/api/status"); }
|
serviceStatus(): Promise<Record<string, unknown>> { return this.request("/api/status"); }
|
||||||
scores(): Promise<Record<string, unknown>> { return this.request("/api/scores"); }
|
scores(): Promise<Record<string, unknown>> { return this.request("/api/scores"); }
|
||||||
refreshScores(): Promise<Record<string, unknown>> {
|
|
||||||
return this.request("/api/scores/refresh", { method: "POST", body: "{}" }, this.config.monitor.cli.timeoutMs + 5_000);
|
|
||||||
}
|
|
||||||
ranking(): Promise<Record<string, unknown>> { return this.request("/api/ranking"); }
|
ranking(): Promise<Record<string, unknown>> { return this.request("/api/ranking"); }
|
||||||
lottery(): Promise<Record<string, unknown>> { return this.request("/api/lottery"); }
|
lottery(): Promise<Record<string, unknown>> { return this.request("/api/lottery"); }
|
||||||
workflowSubmit(command: Record<string, unknown>): Promise<Record<string, unknown>> {
|
workflowSubmit(command: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ const adminToken = process.env[target.adminTokenEnv];
|
|||||||
if (!adminToken) throw new Error(`server target requires env ${target.adminTokenEnv}`);
|
if (!adminToken) throw new Error(`server target requires env ${target.adminTokenEnv}`);
|
||||||
|
|
||||||
const context = createServerContext(config, target);
|
const context = createServerContext(config, target);
|
||||||
const temporal = await TemporalGateway.connect(config);
|
const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId });
|
||||||
const dispatcher = new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal);
|
const dispatcher = new ApplicationDispatcher({ lottery: context.service, scores: context.monitor }, temporal);
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
hostname: target.listenHost,
|
hostname: target.listenHost,
|
||||||
@@ -27,7 +27,7 @@ console.log(JSON.stringify({
|
|||||||
runtime: runtimeId,
|
runtime: runtimeId,
|
||||||
listen: server.url.toString(),
|
listen: server.url.toString(),
|
||||||
temporalNamespace: config.temporal.namespace,
|
temporalNamespace: config.temporal.namespace,
|
||||||
temporalTaskQueue: config.temporal.taskQueue,
|
temporalTaskQueue: target.temporalTaskQueue,
|
||||||
automaticCreditEnabled: config.lottery.automaticCredit.enabled,
|
automaticCreditEnabled: config.lottery.automaticCredit.enabled,
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
+3
-2
@@ -6,6 +6,7 @@ import { LotteryStore } from "./store";
|
|||||||
import { Sub2ApiClient } from "./sub2api-client";
|
import { Sub2ApiClient } from "./sub2api-client";
|
||||||
import { AccountScoreService } from "./account-score-service";
|
import { AccountScoreService } from "./account-score-service";
|
||||||
import type { WebAuthSecrets } from "./web-auth";
|
import type { WebAuthSecrets } from "./web-auth";
|
||||||
|
import { UniDeskRuntimePolicyEventSource } from "./runtime-policy-events";
|
||||||
|
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
service: LotteryService;
|
service: LotteryService;
|
||||||
@@ -18,7 +19,7 @@ export interface AppContext {
|
|||||||
export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext {
|
export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext {
|
||||||
const store = new LotteryStore(config, resolveDataPath(config, target.databasePath));
|
const store = new LotteryStore(config, resolveDataPath(config, target.databasePath));
|
||||||
const client = new Sub2ApiClient(config, readSub2ApiCredentials(config));
|
const client = new Sub2ApiClient(config, readSub2ApiCredentials(config));
|
||||||
const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), target.monitorWorkDir);
|
const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), client, new UniDeskRuntimePolicyEventSource(config, target.monitorWorkDir));
|
||||||
return {
|
return {
|
||||||
service: new LotteryService(config, store, client),
|
service: new LotteryService(config, store, client),
|
||||||
store,
|
store,
|
||||||
@@ -39,7 +40,7 @@ export function createServerContext(config: AppConfig, target: ServerTarget): Ap
|
|||||||
}
|
}
|
||||||
const store = new LotteryStore(config, resolveDataPath(config, target.databasePath));
|
const store = new LotteryStore(config, resolveDataPath(config, target.databasePath));
|
||||||
const client = new Sub2ApiClient(config, { email, password });
|
const client = new Sub2ApiClient(config, { email, password });
|
||||||
const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), target.monitorWorkDir);
|
const monitor = new AccountScoreService(config, resolveDataPath(config, target.scoreCachePath), client, new UniDeskRuntimePolicyEventSource(config, target.monitorWorkDir));
|
||||||
return {
|
return {
|
||||||
service: new LotteryService(config, store, client),
|
service: new LotteryService(config, store, client),
|
||||||
store,
|
store,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface EmbeddedCliTarget {
|
|||||||
databasePath: string;
|
databasePath: string;
|
||||||
scoreCachePath: string;
|
scoreCachePath: string;
|
||||||
monitorWorkDir: string;
|
monitorWorkDir: string;
|
||||||
|
temporalTaskQueue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpCliTarget {
|
export interface HttpCliTarget {
|
||||||
@@ -101,6 +102,8 @@ export interface ServerTarget {
|
|||||||
databasePath: string;
|
databasePath: string;
|
||||||
scoreCachePath: string;
|
scoreCachePath: string;
|
||||||
monitorWorkDir: string;
|
monitorWorkDir: string;
|
||||||
|
temporalTaskQueue: string;
|
||||||
|
scoreScheduleWorkflowId: string;
|
||||||
adminTokenEnv: string;
|
adminTokenEnv: string;
|
||||||
sub2apiAdminEmailEnv: string;
|
sub2apiAdminEmailEnv: string;
|
||||||
sub2apiAdminPasswordEnv: string;
|
sub2apiAdminPasswordEnv: string;
|
||||||
@@ -214,6 +217,7 @@ export function loadConfig(path: string): AppConfig {
|
|||||||
databasePath: stringValue(target, "databasePath", `runtime.cliTargets.${id}`),
|
databasePath: stringValue(target, "databasePath", `runtime.cliTargets.${id}`),
|
||||||
scoreCachePath: stringValue(target, "scoreCachePath", `runtime.cliTargets.${id}`),
|
scoreCachePath: stringValue(target, "scoreCachePath", `runtime.cliTargets.${id}`),
|
||||||
monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.cliTargets.${id}`),
|
monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.cliTargets.${id}`),
|
||||||
|
temporalTaskQueue: stringValue(target, "temporalTaskQueue", `runtime.cliTargets.${id}`),
|
||||||
};
|
};
|
||||||
else if (mode === "http") cliTargets[id] = { mode, baseUrl: stringValue(target, "baseUrl", `runtime.cliTargets.${id}`), adminToken: secretRef(target.adminToken, `runtime.cliTargets.${id}.adminToken`) };
|
else if (mode === "http") cliTargets[id] = { mode, baseUrl: stringValue(target, "baseUrl", `runtime.cliTargets.${id}`), adminToken: secretRef(target.adminToken, `runtime.cliTargets.${id}.adminToken`) };
|
||||||
else throw new Error(`runtime.cliTargets.${id}.mode must be embedded or http`);
|
else throw new Error(`runtime.cliTargets.${id}.mode must be embedded or http`);
|
||||||
@@ -233,6 +237,8 @@ export function loadConfig(path: string): AppConfig {
|
|||||||
databasePath: stringValue(target, "databasePath", `runtime.serverTargets.${id}`),
|
databasePath: stringValue(target, "databasePath", `runtime.serverTargets.${id}`),
|
||||||
scoreCachePath: stringValue(target, "scoreCachePath", `runtime.serverTargets.${id}`),
|
scoreCachePath: stringValue(target, "scoreCachePath", `runtime.serverTargets.${id}`),
|
||||||
monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.serverTargets.${id}`),
|
monitorWorkDir: stringValue(target, "monitorWorkDir", `runtime.serverTargets.${id}`),
|
||||||
|
temporalTaskQueue: stringValue(target, "temporalTaskQueue", `runtime.serverTargets.${id}`),
|
||||||
|
scoreScheduleWorkflowId: stringValue(target, "scoreScheduleWorkflowId", `runtime.serverTargets.${id}`),
|
||||||
adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`),
|
adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`),
|
||||||
sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`),
|
sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`),
|
||||||
sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`),
|
sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`),
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { AppConfig } from "./config";
|
||||||
|
import type { Sub2ApiSystemLog } from "./sub2api-client";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface RuntimePolicyEventBatch {
|
||||||
|
events: Sub2ApiSystemLog[];
|
||||||
|
evidence: Row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimePolicyEventSource {
|
||||||
|
collect(window: string): Promise<RuntimePolicyEventBatch>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Row | null {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | null {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return value !== null && value !== undefined && Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCliPayload(stdout: string): Row {
|
||||||
|
let payload = record(JSON.parse(stdout));
|
||||||
|
if (!payload) throw new Error("runtime events CLI returned an invalid payload");
|
||||||
|
const outerData = record(payload.data);
|
||||||
|
if (outerData?.outputTruncated === true) {
|
||||||
|
const dumpPath = text(record(outerData.dump)?.path);
|
||||||
|
if (!dumpPath || !dumpPath.startsWith("/tmp/unidesk-cli-output/") || !existsSync(dumpPath)) {
|
||||||
|
throw new Error("runtime events CLI did not expose its protected raw result");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
payload = record(JSON.parse(readFileSync(dumpPath, "utf8")));
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(dumpPath); } catch { /* The CLI may clean up its own result. */ }
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error("runtime events CLI raw result is invalid");
|
||||||
|
}
|
||||||
|
const response = record(payload.data);
|
||||||
|
const parsed = record(response?.parsed);
|
||||||
|
if (payload.ok !== true || response?.ok !== true || parsed?.ok !== true) {
|
||||||
|
throw new Error(`runtime events CLI failed: ${text(parsed?.error) ?? "unknown error"}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UniDeskRuntimePolicyEventSource implements RuntimePolicyEventSource {
|
||||||
|
constructor(private readonly config: AppConfig, private readonly workDir: string) {}
|
||||||
|
|
||||||
|
async collect(window: string): Promise<RuntimePolicyEventBatch> {
|
||||||
|
const args = [
|
||||||
|
resolve(this.workDir, this.config.monitor.cli.entrypoint),
|
||||||
|
"platform-infra", "sub2api", "codex-pool", "runtime", "events",
|
||||||
|
"--target", this.config.monitor.target,
|
||||||
|
"--since", window,
|
||||||
|
"--raw",
|
||||||
|
];
|
||||||
|
const child = Bun.spawn([this.config.monitor.cli.executable, ...args], {
|
||||||
|
cwd: this.workDir,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...Bun.env, UNIDESK_MAIN_SERVER_IP: this.config.monitor.cli.mainServerHost },
|
||||||
|
});
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill();
|
||||||
|
}, this.config.monitor.cli.timeoutMs);
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
child.exited,
|
||||||
|
]).finally(() => clearTimeout(timeout));
|
||||||
|
if (timedOut) throw new Error(`runtime events CLI timed out after ${this.config.monitor.cli.timeoutMs}ms`);
|
||||||
|
if (exitCode !== 0) throw new Error(`runtime events CLI exited ${exitCode}: ${stderr.trim().slice(-600)}`);
|
||||||
|
const payload = readCliPayload(stdout);
|
||||||
|
const rows = Array.isArray(payload.events) ? payload.events.map(record).filter((item): item is Row => item !== null) : [];
|
||||||
|
const events = rows.map((row, index): Sub2ApiSystemLog => ({
|
||||||
|
id: index + 1,
|
||||||
|
created_at: text(row.createdAt) ?? "",
|
||||||
|
message: text(row.marker) ?? "",
|
||||||
|
request_id: text(row.requestId) ?? undefined,
|
||||||
|
account_id: numberValue(row.accountId),
|
||||||
|
extra: {
|
||||||
|
group_id: numberValue(row.groupId),
|
||||||
|
status_code: numberValue(row.statusCode),
|
||||||
|
upstream_status: numberValue(row.upstreamStatus),
|
||||||
|
path: text(row.path),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
evidence: {
|
||||||
|
source: payload.source,
|
||||||
|
window: payload.window,
|
||||||
|
tail: payload.tail,
|
||||||
|
scannedLineCount: payload.scannedLineCount,
|
||||||
|
policyEventCount: payload.policyEventCount,
|
||||||
|
completionEventCount: payload.completionEventCount,
|
||||||
|
eventCount: payload.eventCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
-1
@@ -7,7 +7,7 @@ interface Envelope<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Paginated<T> {
|
export interface Paginated<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -15,6 +15,56 @@ interface Paginated<T> {
|
|||||||
pages: number;
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiAccount {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
status: string;
|
||||||
|
schedulable?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiUsageRow {
|
||||||
|
id: number;
|
||||||
|
account_id: number | null;
|
||||||
|
group_id: number | null;
|
||||||
|
model: string;
|
||||||
|
stream: boolean;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
actual_cost: number;
|
||||||
|
duration_ms: number | null;
|
||||||
|
first_token_ms: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiRequestError {
|
||||||
|
id: number;
|
||||||
|
request_id?: string;
|
||||||
|
account_id?: number | null;
|
||||||
|
status_code?: number;
|
||||||
|
phase?: string;
|
||||||
|
type?: string;
|
||||||
|
message?: string;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiSystemLog {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
message: string;
|
||||||
|
request_id?: string;
|
||||||
|
account_id?: number | null;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export class Sub2ApiClient {
|
export class Sub2ApiClient {
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
private tokenExpiresAt = 0;
|
private tokenExpiresAt = 0;
|
||||||
@@ -85,6 +135,85 @@ export class Sub2ApiClient {
|
|||||||
return await this.request(`/admin/dashboard/users-ranking?${params}`);
|
return await this.request(`/admin/dashboard/users-ranking?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listGroups(): Promise<Sub2ApiGroup[]> {
|
||||||
|
return await this.request<Sub2ApiGroup[]>("/admin/groups/all");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroupAccounts(groupId: number, platform: string): Promise<Sub2ApiAccount[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
page_size: "1000",
|
||||||
|
group: String(groupId),
|
||||||
|
platform,
|
||||||
|
sort_by: "id",
|
||||||
|
sort_order: "asc",
|
||||||
|
});
|
||||||
|
return (await this.request<Paginated<Sub2ApiAccount>>(`/admin/accounts?${params}`)).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroupUsage(groupId: number, start: Date, end: Date): Promise<Sub2ApiUsageRow[]> {
|
||||||
|
return await this.paginate<Sub2ApiUsageRow>("/admin/usage", {
|
||||||
|
group_id: String(groupId),
|
||||||
|
start_date: start.toISOString().slice(0, 10),
|
||||||
|
end_date: end.toISOString().slice(0, 10),
|
||||||
|
timezone: "UTC",
|
||||||
|
sort_by: "created_at",
|
||||||
|
sort_order: "desc",
|
||||||
|
}, 1000, (row) => {
|
||||||
|
const createdAt = Date.parse(row.created_at);
|
||||||
|
return Number.isFinite(createdAt) && createdAt >= start.getTime() && createdAt <= end.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRequestErrors(groupId: number, platform: string, start: Date): Promise<Sub2ApiRequestError[]> {
|
||||||
|
return await this.paginate<Sub2ApiRequestError>("/admin/ops/request-errors", {
|
||||||
|
group_id: String(groupId),
|
||||||
|
platform,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
view: "all",
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSystemLogs(platform: string, start: Date, marker: string): Promise<Sub2ApiSystemLog[]> {
|
||||||
|
return await this.paginate<Sub2ApiSystemLog>("/admin/ops/system-logs", {
|
||||||
|
platform,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
q: marker,
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpsOverview(groupId: number, platform: string, start: Date): Promise<Record<string, unknown>> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
group_id: String(groupId),
|
||||||
|
platform,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
query_mode: "raw",
|
||||||
|
});
|
||||||
|
return await this.request(`/admin/ops/dashboard/overview?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpsAccountAvailability(groupId: number, platform: string): Promise<Record<string, unknown>> {
|
||||||
|
const params = new URLSearchParams({ group_id: String(groupId), platform });
|
||||||
|
return await this.request(`/admin/ops/account-availability?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpsConcurrency(groupId: number, platform: string): Promise<Record<string, unknown>> {
|
||||||
|
const params = new URLSearchParams({ group_id: String(groupId), platform });
|
||||||
|
return await this.request(`/admin/ops/concurrency?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async paginate<T>(path: string, query: Record<string, string>, pageSize: number, keep: (row: T) => boolean = () => true): Promise<T[]> {
|
||||||
|
const rows: T[] = [];
|
||||||
|
let page = 1;
|
||||||
|
for (;;) {
|
||||||
|
const params = new URLSearchParams({ ...query, page: String(page), page_size: String(pageSize) });
|
||||||
|
const data = await this.request<Paginated<T>>(`${path}?${params}`);
|
||||||
|
rows.push(...data.items.filter(keep));
|
||||||
|
if (page >= data.pages) return rows;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async addBalance(userId: number, amountUsd: number, notes: string): Promise<Sub2ApiUser> {
|
async addBalance(userId: number, amountUsd: number, notes: string): Promise<Sub2ApiUser> {
|
||||||
return await this.request(`/admin/users/${userId}/balance`, {
|
return await this.request(`/admin/users/${userId}/balance`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ export class TemporalGateway {
|
|||||||
private readonly connection: Connection,
|
private readonly connection: Connection,
|
||||||
private readonly client: Client,
|
private readonly client: Client,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
|
private readonly runtime: { taskQueue: string; scoreScheduleWorkflowId: string },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static async connect(config: AppConfig): Promise<TemporalGateway> {
|
static async connect(config: AppConfig, runtime: { taskQueue: string; scoreScheduleWorkflowId?: string }): Promise<TemporalGateway> {
|
||||||
const connection = await Connection.connect({ address: temporalAddress(config) });
|
const connection = await Connection.connect({ address: temporalAddress(config) });
|
||||||
return new TemporalGateway(connection, new Client({ connection, namespace: config.temporal.namespace }), config);
|
return new TemporalGateway(connection, new Client({ connection, namespace: config.temporal.namespace }), config, {
|
||||||
|
taskQueue: runtime.taskQueue,
|
||||||
|
scoreScheduleWorkflowId: runtime.scoreScheduleWorkflowId ?? config.temporal.scoreScheduleWorkflowId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(command: AppCommand): Promise<unknown> {
|
async execute(command: AppCommand): Promise<unknown> {
|
||||||
@@ -29,7 +33,7 @@ export class TemporalGateway {
|
|||||||
async submit(command: AppCommand): Promise<{ ok: true; workflowId: string; runId: string; state: "submitted" }> {
|
async submit(command: AppCommand): Promise<{ ok: true; workflowId: string; runId: string; state: "submitted" }> {
|
||||||
const operation: OperationRequest = { operationId: randomUUID(), command };
|
const operation: OperationRequest = { operationId: randomUUID(), command };
|
||||||
const handle = await this.client.workflow.start("operationWorkflow", {
|
const handle = await this.client.workflow.start("operationWorkflow", {
|
||||||
taskQueue: this.config.temporal.taskQueue,
|
taskQueue: this.runtime.taskQueue,
|
||||||
workflowId: `apistate-${command.kind.replaceAll(".", "-")}-${operation.operationId}`,
|
workflowId: `apistate-${command.kind.replaceAll(".", "-")}-${operation.operationId}`,
|
||||||
workflowExecutionTimeout: this.config.temporal.workflowExecutionTimeout,
|
workflowExecutionTimeout: this.config.temporal.workflowExecutionTimeout,
|
||||||
args: [{
|
args: [{
|
||||||
@@ -56,10 +60,10 @@ export class TemporalGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureScoreSchedule(): Promise<{ started: boolean; workflowId: string }> {
|
async ensureScoreSchedule(): Promise<{ started: boolean; workflowId: string }> {
|
||||||
const workflowId = this.config.temporal.scoreScheduleWorkflowId;
|
const workflowId = this.runtime.scoreScheduleWorkflowId;
|
||||||
try {
|
try {
|
||||||
await this.client.workflow.start("scoreRefreshScheduleWorkflow", {
|
await this.client.workflow.start("scoreRefreshScheduleWorkflow", {
|
||||||
taskQueue: this.config.temporal.taskQueue,
|
taskQueue: this.runtime.taskQueue,
|
||||||
workflowId,
|
workflowId,
|
||||||
args: [{
|
args: [{
|
||||||
intervalMs: this.config.monitor.refreshIntervalMinutes * 60_000,
|
intervalMs: this.config.monitor.refreshIntervalMinutes * 60_000,
|
||||||
|
|||||||
+4
-4
@@ -16,11 +16,11 @@ const connection = await NativeConnection.connect({ address: temporalAddress(con
|
|||||||
const worker = await Worker.create({
|
const worker = await Worker.create({
|
||||||
connection,
|
connection,
|
||||||
namespace: config.temporal.namespace,
|
namespace: config.temporal.namespace,
|
||||||
taskQueue: config.temporal.taskQueue,
|
taskQueue: target.temporalTaskQueue,
|
||||||
workflowsPath: fileURLToPath(new URL("./workflows.ts", import.meta.url)),
|
workflowsPath: fileURLToPath(new URL("./workflows.ts", import.meta.url)),
|
||||||
activities: createActivities({ lottery: context.service, scores: context.monitor }),
|
activities: createActivities({ lottery: context.service, scores: context.monitor }),
|
||||||
});
|
});
|
||||||
const temporal = await TemporalGateway.connect(config);
|
const temporal = await TemporalGateway.connect(config, { taskQueue: target.temporalTaskQueue, scoreScheduleWorkflowId: target.scoreScheduleWorkflowId });
|
||||||
const schedule = await temporal.ensureScoreSchedule();
|
const schedule = await temporal.ensureScoreSchedule();
|
||||||
let state: "ready" | "stopping" = "ready";
|
let state: "ready" | "stopping" = "ready";
|
||||||
const health = Bun.serve({
|
const health = Bun.serve({
|
||||||
@@ -31,7 +31,7 @@ const health = Bun.serve({
|
|||||||
component: "apistate-worker",
|
component: "apistate-worker",
|
||||||
state,
|
state,
|
||||||
namespace: config.temporal.namespace,
|
namespace: config.temporal.namespace,
|
||||||
taskQueue: config.temporal.taskQueue,
|
taskQueue: target.temporalTaskQueue,
|
||||||
schedule,
|
schedule,
|
||||||
}, { status: state === "ready" ? 200 : 503 }),
|
}, { status: state === "ready" ? 200 : 503 }),
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ console.log(JSON.stringify({
|
|||||||
runtime: runtimeId,
|
runtime: runtimeId,
|
||||||
health: health.url.toString(),
|
health: health.url.toString(),
|
||||||
temporalNamespace: config.temporal.namespace,
|
temporalNamespace: config.temporal.namespace,
|
||||||
temporalTaskQueue: config.temporal.taskQueue,
|
temporalTaskQueue: target.temporalTaskQueue,
|
||||||
schedule,
|
schedule,
|
||||||
valuesPrinted: false,
|
valuesPrinted: false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user