Files
UHD_Media_Tool/apps/server/src/modules/pi/refresh.ts
T
rootandClaude Opus 5.5 31a54186c7 copy: second-round copy review across console, settings, agent and server messages
- Trim descriptions, empty states, confirm buttons and toasts to facts
- Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游
- Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk)
- Shorter, concrete goal templates for built-in Agent checks
- Human-readable nouns instead of table names in instance CRUD errors
- Audit labels for pi_upstream / schedule / automation_grant actions
- Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 13:29:42 +08:00

110 lines
4.8 KiB
TypeScript

import { PiUpstreamClient, type UpstreamClientOptions } from "@umt/agent";
import { type PiUpstreamRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { dbCredentialStore, setStatus, specOf } from "./store.ts";
// refreshPiTokens:对即将过期的 OAuth 上游提前刷新。刷新本身走 pi 的 getAuth(minOAuthValidityMs):
// pi 在我们的 CredentialStore.modify(pi_upstream 行锁)里双重检查过期时间,再调 provider 的 refresh 并写回轮换后的
// token —— Agent 运行时(worker)与测试端点(api)的按需刷新走的是同一条路径,不会双写。
// 刷新失败(refresh token 失效)→ status = expired + 待处理事项“上游 X 需要重新登录”;旧凭据保留。
export interface RefreshOptions {
/** 剩余有效期少于它就刷新(默认 30 分钟) */
windowMs?: number;
client?: UpstreamClientOptions;
}
export interface RefreshReport {
checked: number;
refreshed: number;
failed: { upstreamId: string; message: string }[];
}
const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
values ('other', 'warning', ${title}, ${message}, 'pi_upstream', ${row.id}, ${DECISION_KEY(row.id)},
${JSON.stringify({ provider: row.provider, upstreamId: row.id, error: message })}::jsonb,
${JSON.stringify([{ key: "relogin", label: "重新登录", description: "设置 → Agent → 上游" }])}::jsonb)
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now()
returning id, (xmax = 0) as inserted`.execute(trx);
const d = r.rows[0]!;
if (d.inserted)
await ctx.outbox.emit(
trx,
"decision.created",
{ decisionId: d.id, kind: "other", severity: "warning", title },
{ idempotencyKey: `decision.created:${d.id}` },
);
});
}
/** 重新登录成功后关闭“需要重新登录”事项 */
export async function resolveExpiredDecision(ctx: ServerContext, upstreamId: string) {
await ctx.db
.updateTable("decision")
.set({
status: "resolved",
resolved_at: new Date(),
resolved_by: "system:pi",
resolution: JSON.stringify({ key: "relogin" }),
})
.where("dedupe_key", "=", DECISION_KEY(upstreamId))
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
}
/** 刷新单个上游(api 测试端点与定时任务共用) */
export async function refreshUpstream(
ctx: ServerContext,
row: PiUpstreamRow,
opts: RefreshOptions = {},
): Promise<{ ok: true } | { ok: false; message: string; kind: string }> {
const client = new PiUpstreamClient(specOf(row), dbCredentialStore(ctx, row.id), opts.client);
try {
await client.resolveAuth(opts.windowMs ?? 30 * 60_000);
return { ok: true };
} catch (err) {
const e = err as { kind?: string; message?: string };
const message = e.message ?? String(err);
if (e.kind === "oauth") {
await setStatus(ctx, row.id, { status: "expired", lastError: message });
await upsertExpiredDecision(ctx, row, message);
} else if (e.kind === "auth") {
await setStatus(ctx, row.id, { status: "auth_required", lastError: message });
}
return { ok: false, message, kind: e.kind ?? "other" };
}
}
export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions = {}): Promise<RefreshReport> {
const windowMs = opts.windowMs ?? 30 * 60_000;
const rows = await ctx.db
.selectFrom("pi_upstream")
.selectAll()
.where("auth_kind", "=", "oauth")
.where("enabled", "=", true)
.where("secret_ref", "is not", null)
.where("status", "!=", "expired")
.where("oauth_expires_at", "is not", null)
.where("oauth_expires_at", "<", new Date(Date.now() + windowMs))
.execute();
const report: RefreshReport = { checked: rows.length, refreshed: 0, failed: [] };
for (const row of rows) {
const r = await refreshUpstream(ctx, row, { ...opts, windowMs });
if (r.ok) report.refreshed += 1;
else {
report.failed.push({ upstreamId: row.id, message: r.message });
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "Agent 上游 token 刷新失败");
}
}
return report;
}