feat(desktop): drop parked review flow, add publish management
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除 manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询, desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次 发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与 媒体库;plan A / spec 文档同步口径。
This commit is contained in:
@@ -17,7 +17,16 @@ import {
|
||||
type MonitorAdapter,
|
||||
type PublishAdapter,
|
||||
} from "./adapters";
|
||||
import { ensurePublishAccountSessionHandle, resolvePublishAccountProfile, type PublishAccountProfile } from "./account-binder";
|
||||
import {
|
||||
ensurePublishAccountSessionHandle,
|
||||
inspectPublishAccountLocalSession,
|
||||
type PublishAccountProfile,
|
||||
} from "./account-binder";
|
||||
import {
|
||||
buildAccountIdentityKey,
|
||||
normalizeAccountPlatformUid,
|
||||
sameAccountPlatformUid,
|
||||
} from "../shared/account-identity";
|
||||
import { acquireHotView } from "./view-pool";
|
||||
import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager";
|
||||
import {
|
||||
@@ -34,11 +43,11 @@ import {
|
||||
setSchedulerPhase,
|
||||
setSchedulerQueueDepth,
|
||||
} from "./scheduler";
|
||||
import { createSessionHandle, getSessionHandle } from "./session-registry";
|
||||
import { openReviewWindow } from "./review-window";
|
||||
import { clearSessionHandle, createSessionHandle, getSessionHandle } from "./session-registry";
|
||||
import {
|
||||
completeDesktopTask,
|
||||
configureTransport,
|
||||
deleteDesktopAccount,
|
||||
extendDesktopTask,
|
||||
getDesktopArticleContent,
|
||||
heartbeatDesktopClient,
|
||||
@@ -46,9 +55,11 @@ import {
|
||||
listDesktopAccounts,
|
||||
noteTransportHeartbeat,
|
||||
noteTransportPull,
|
||||
parkDesktopTask,
|
||||
offlineDesktopClient,
|
||||
revokeDesktopClient,
|
||||
setTransportAuthState,
|
||||
setTransportSseState,
|
||||
upsertDesktopAccount,
|
||||
} from "./transport/api-client";
|
||||
import { SseClient, SseClientError } from "./transport/sse-client";
|
||||
|
||||
@@ -56,17 +67,14 @@ type RuntimeTone = "info" | "success" | "warn" | "danger";
|
||||
type RuntimeTaskStatus =
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
| "waiting_user"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "unknown"
|
||||
| "aborted";
|
||||
type RuntimeTaskMode = "auto" | "manual";
|
||||
type RuntimeTaskRouting = "rabbitmq-primary" | "db-recovery";
|
||||
|
||||
const heartbeatIntervalMs = 25_000;
|
||||
const pullIntervalMs = 60_000;
|
||||
const accountsSyncIntervalMs = 90_000;
|
||||
const leaseExtendIntervalMs = 60_000;
|
||||
const maxActivityItems = 60;
|
||||
|
||||
@@ -80,7 +88,6 @@ interface RuntimeTaskRecord {
|
||||
accountName: string;
|
||||
clientId: string;
|
||||
status: RuntimeTaskStatus;
|
||||
mode: RuntimeTaskMode;
|
||||
routing: RuntimeTaskRouting;
|
||||
leaseExpiresAt: number | null;
|
||||
updatedAt: number;
|
||||
@@ -140,7 +147,6 @@ interface RuntimeState {
|
||||
leaseInFlight: boolean;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
accountsTimer: ReturnType<typeof setInterval> | null;
|
||||
sseClient: SseClient | null;
|
||||
lastHeartbeatAt: number;
|
||||
lastHeartbeatStatus: "idle" | "success" | "failed";
|
||||
@@ -168,7 +174,6 @@ const state: RuntimeState = {
|
||||
leaseInFlight: false,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
accountsTimer: null,
|
||||
sseClient: null,
|
||||
lastHeartbeatAt: 0,
|
||||
lastHeartbeatStatus: "idle",
|
||||
@@ -213,6 +218,33 @@ export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseRuntimeSession(options: { revoke?: boolean } = {}): Promise<void> {
|
||||
const currentSession = state.session;
|
||||
const shouldReleaseRemote = canRunLive(currentSession);
|
||||
|
||||
if (shouldReleaseRemote) {
|
||||
try {
|
||||
if (options.revoke) {
|
||||
await revokeDesktopClient();
|
||||
} else {
|
||||
await offlineDesktopClient();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] release runtime session failed", {
|
||||
revoke: Boolean(options.revoke),
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopRuntime();
|
||||
clearRuntimeState();
|
||||
configureTransport(null);
|
||||
setTransportAuthState("pending");
|
||||
state.session = null;
|
||||
state.client = null;
|
||||
}
|
||||
|
||||
export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
||||
return {
|
||||
session: state.session ? { ...state.session } : null,
|
||||
@@ -238,103 +270,6 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
export async function openTaskReview(taskId: string): Promise<void> {
|
||||
const task = state.tasks.get(taskId);
|
||||
if (!task) {
|
||||
throw new Error("desktop_task_not_found");
|
||||
}
|
||||
if (task.kind !== "publish") {
|
||||
throw new Error("desktop_task_review_only_for_publish");
|
||||
}
|
||||
|
||||
const reviewURL = resolveTaskReviewUrl(task);
|
||||
if (!reviewURL) {
|
||||
throw new Error("desktop_task_review_url_missing");
|
||||
}
|
||||
|
||||
const sessionHandle = createSessionHandle(task.accountId || task.id);
|
||||
await openReviewWindow({
|
||||
taskId: task.id,
|
||||
title: `${task.title} · 审核`,
|
||||
url: reviewURL,
|
||||
session: sessionHandle.session,
|
||||
});
|
||||
|
||||
recordActivity("info", "Review Window Opened", `${task.title} 已打开审核页。`);
|
||||
}
|
||||
|
||||
export async function resolveParkedTask(
|
||||
taskId: string,
|
||||
status: "succeeded" | "failed" | "unknown",
|
||||
): Promise<void> {
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (!existing) {
|
||||
throw new Error("desktop_task_not_found");
|
||||
}
|
||||
if (existing.status !== "waiting_user") {
|
||||
throw new Error("desktop_task_not_waiting_user");
|
||||
}
|
||||
if (!canRunLive(state.session)) {
|
||||
throw new Error("desktop_runtime_not_authenticated");
|
||||
}
|
||||
if (state.currentTaskId && state.currentTaskId !== taskId) {
|
||||
throw new Error("desktop_runtime_busy");
|
||||
}
|
||||
|
||||
state.currentTaskId = taskId;
|
||||
setSchedulerCurrentTask(taskId);
|
||||
setSchedulerPhase("running");
|
||||
|
||||
try {
|
||||
const leased = await leaseDesktopTask({ task_id: taskId }, { fromParked: true });
|
||||
if (!leased.task || !leased.lease_token) {
|
||||
throw new Error("desktop_task_resume_lease_missing");
|
||||
}
|
||||
|
||||
setActiveLease({
|
||||
taskId,
|
||||
attemptId: leased.attempt_id ?? null,
|
||||
leaseExpiresAt: leased.lease_expires_at ?? leased.task.lease_expires_at ?? null,
|
||||
});
|
||||
|
||||
const resumed = upsertTaskFromInfo(leased.task, {
|
||||
routing: existing.routing,
|
||||
mode: existing.mode,
|
||||
attemptId: leased.attempt_id ?? null,
|
||||
leaseToken: leased.lease_token,
|
||||
summary: "已从 parked 重新领取租约,准备回写人工审核结果。",
|
||||
});
|
||||
|
||||
const resolutionPayload = buildManualResolutionPayload(resumed, status);
|
||||
const completed = await completeDesktopTask(taskId, {
|
||||
lease_token: leased.lease_token,
|
||||
status,
|
||||
payload: resolutionPayload.payload,
|
||||
error: resolutionPayload.error,
|
||||
});
|
||||
|
||||
noteLeaseReleased(status === "failed" ? "failed" : "completed");
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing: existing.routing,
|
||||
mode: existing.mode,
|
||||
summary: resolutionPayload.summary,
|
||||
attemptId: null,
|
||||
leaseToken: null,
|
||||
});
|
||||
|
||||
recordActivity(
|
||||
status === "failed" ? "danger" : status === "unknown" ? "warn" : "success",
|
||||
"Parked Task Resolved",
|
||||
`${existing.title} 已按人工审核结果回写为 ${status}。`,
|
||||
);
|
||||
} finally {
|
||||
state.currentTaskId = null;
|
||||
setSchedulerCurrentTask(null);
|
||||
noteSchedulerTaskFinished();
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function startRuntime(): void {
|
||||
if (!canRunLive(state.session) || state.running) {
|
||||
return;
|
||||
@@ -349,7 +284,6 @@ function startRuntime(): void {
|
||||
connectEventStream();
|
||||
scheduleHeartbeatLoop();
|
||||
schedulePullLoop();
|
||||
scheduleAccountsSyncLoop();
|
||||
|
||||
void sendHeartbeat("startup");
|
||||
void syncAccounts("startup");
|
||||
@@ -372,10 +306,6 @@ function stopRuntime(): void {
|
||||
clearInterval(state.pullTimer);
|
||||
state.pullTimer = null;
|
||||
}
|
||||
if (state.accountsTimer) {
|
||||
clearInterval(state.accountsTimer);
|
||||
state.accountsTimer = null;
|
||||
}
|
||||
if (state.sseClient) {
|
||||
state.sseClient.stop();
|
||||
state.sseClient = null;
|
||||
@@ -434,16 +364,6 @@ function schedulePullLoop(): void {
|
||||
}, pullIntervalMs);
|
||||
}
|
||||
|
||||
function scheduleAccountsSyncLoop(): void {
|
||||
if (state.accountsTimer) {
|
||||
clearInterval(state.accountsTimer);
|
||||
}
|
||||
|
||||
state.accountsTimer = setInterval(() => {
|
||||
void syncAccounts("timer");
|
||||
}, accountsSyncIntervalMs);
|
||||
}
|
||||
|
||||
function connectEventStream(): void {
|
||||
if (!canRunLive(state.session)) {
|
||||
return;
|
||||
@@ -499,7 +419,6 @@ function connectEventStream(): void {
|
||||
sseClient.on("task_available", taskEventHandler);
|
||||
sseClient.on("task_leased", taskEventHandler);
|
||||
sseClient.on("task_extended", taskEventHandler);
|
||||
sseClient.on("task_parked", taskEventHandler);
|
||||
sseClient.on("task_completed", taskEventHandler);
|
||||
sseClient.on("task_canceled", taskEventHandler);
|
||||
sseClient.on("task_reconciled", taskEventHandler);
|
||||
@@ -541,7 +460,6 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
accountName: existing?.accountName ?? "待同步账号",
|
||||
clientId: event.target_client_id,
|
||||
status: event.status,
|
||||
mode: existing?.mode ?? "auto",
|
||||
routing: existing?.routing ?? "rabbitmq-primary",
|
||||
leaseExpiresAt: event.status === "in_progress" ? existing?.leaseExpiresAt ?? null : null,
|
||||
updatedAt: parseTimestamp(event.updated_at) ?? Date.now(),
|
||||
@@ -575,6 +493,7 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
|
||||
cpu_arch: state.client?.cpu_arch ?? process.arch,
|
||||
client_version: state.client?.client_version ?? "0.1.0-dev",
|
||||
channel: state.client?.channel ?? "dev",
|
||||
account_ids: state.accounts.filter((account) => account.health === "live").map((account) => account.id),
|
||||
});
|
||||
|
||||
state.client = response.client;
|
||||
@@ -609,52 +528,115 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAccounts(source: "startup" | "timer" | "manual"): Promise<void> {
|
||||
async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
if (!canRunLive(state.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await listDesktopAccounts();
|
||||
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
|
||||
state.lastAccountsSyncAt = Date.now();
|
||||
state.lastError = null;
|
||||
|
||||
noteSchedulerAccountsSync();
|
||||
setSchedulerError(null);
|
||||
|
||||
if (source === "startup") {
|
||||
recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`);
|
||||
}
|
||||
|
||||
state.accountProfiles.clear();
|
||||
state.accounts = await Promise.all(accounts.map(async (account) => {
|
||||
await ensurePublishAccountSessionHandle({
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const identity = {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: normalizedPlatformUid,
|
||||
displayName: account.display_name,
|
||||
} as const;
|
||||
const local = await inspectPublishAccountLocalSession(identity);
|
||||
|
||||
if (local.availability === "missing") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (local.profile && !sameAccountPlatformUid(local.profile.platformUid, normalizedPlatformUid)) {
|
||||
return {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
health: "expired" as const,
|
||||
};
|
||||
}
|
||||
|
||||
let resolvedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
health: local.availability === "expired" ? ("expired" as const) : ("live" as const),
|
||||
};
|
||||
|
||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
||||
if (shouldRelinkClient) {
|
||||
try {
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: resolvedAccount.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: resolvedAccount.health,
|
||||
verified_at: new Date().toISOString(),
|
||||
tags: resolvedAccount.tags,
|
||||
});
|
||||
|
||||
resolvedAccount = {
|
||||
...updated,
|
||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||
health: resolvedAccount.health,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] account relink failed", {
|
||||
accountId: resolvedAccount.id,
|
||||
platform: resolvedAccount.platform,
|
||||
platformUid: resolvedAccount.platform_uid,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAccount;
|
||||
}));
|
||||
const duplicateAccounts: DesktopAccountInfo[] = [];
|
||||
const dedupedAccounts: DesktopAccountInfo[] = [];
|
||||
const seenAccountKeys = new Set<string>();
|
||||
|
||||
for (const account of syncedAccounts) {
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const identityKey = buildAccountIdentityKey({
|
||||
platform: account.platform,
|
||||
platformUid: account.platform_uid,
|
||||
displayName: account.display_name,
|
||||
});
|
||||
|
||||
const profile = await resolvePublishAccountProfile({
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: account.platform_uid,
|
||||
displayName: account.display_name,
|
||||
}).catch(() => null);
|
||||
|
||||
if (profile) {
|
||||
state.accountProfiles.set(account.id, profile);
|
||||
return {
|
||||
...account,
|
||||
display_name: profile.displayName,
|
||||
platform_uid: profile.platformUid,
|
||||
};
|
||||
if (seenAccountKeys.has(identityKey)) {
|
||||
duplicateAccounts.push(account);
|
||||
continue;
|
||||
}
|
||||
|
||||
return account;
|
||||
}));
|
||||
seenAccountKeys.add(identityKey);
|
||||
dedupedAccounts.push(account);
|
||||
}
|
||||
|
||||
state.accounts = dedupedAccounts;
|
||||
|
||||
if (duplicateAccounts.length > 0) {
|
||||
void cleanupDuplicateDesktopAccounts(duplicateAccounts);
|
||||
}
|
||||
|
||||
if (source === "startup") {
|
||||
recordActivity("info", "Accounts Synced", `已同步 ${state.accounts.length} 个桌面账号。`);
|
||||
}
|
||||
|
||||
refreshAccountNames();
|
||||
void sendHeartbeat("timer");
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
state.lastError = message;
|
||||
@@ -669,10 +651,46 @@ async function syncAccounts(source: "startup" | "timer" | "manual"): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
await deleteDesktopAccount(account.id, account.sync_version);
|
||||
} catch (error) {
|
||||
console.warn("[desktop-runtime] duplicate desktop account cleanup failed", {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: account.platform_uid,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshRuntimeAccounts(): Promise<void> {
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
export async function unbindRuntimeAccount(accountId: string, syncVersion: number): Promise<void> {
|
||||
const matchedAccount = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||
|
||||
await deleteDesktopAccount(accountId, syncVersion);
|
||||
await clearSessionHandle(accountId);
|
||||
|
||||
state.accounts = state.accounts.filter((item) => item.id !== accountId);
|
||||
state.accountProfiles.delete(accountId);
|
||||
refreshAccountNames();
|
||||
|
||||
if (matchedAccount) {
|
||||
recordActivity(
|
||||
"info",
|
||||
"Account Unbound",
|
||||
`${matchedAccount.display_name} 已解绑,并清理了当前机器上的本地会话缓存。`,
|
||||
);
|
||||
}
|
||||
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void {
|
||||
if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) {
|
||||
return;
|
||||
@@ -787,8 +805,6 @@ async function executeLeasedTask(
|
||||
}
|
||||
|
||||
const task = leased.task;
|
||||
const payload = normalizeJsonObject(task.payload);
|
||||
const mode = resolveTaskMode(payload);
|
||||
|
||||
state.currentTaskId = task.id;
|
||||
setSchedulerCurrentTask(task.id);
|
||||
@@ -802,13 +818,9 @@ async function executeLeasedTask(
|
||||
|
||||
const taskRecord = upsertTaskFromInfo(task, {
|
||||
routing,
|
||||
mode,
|
||||
attemptId: leased.attempt_id ?? null,
|
||||
leaseToken: leased.lease_token,
|
||||
summary:
|
||||
mode === "manual" && task.kind === "publish"
|
||||
? "已领取租约,准备切到 waiting_user parked 状态。"
|
||||
: `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`,
|
||||
summary: `已领取租约,开始执行 ${routing === "rabbitmq-primary" ? "MQ 唤醒" : "pull fallback"} 消费。`,
|
||||
});
|
||||
|
||||
recordActivity(
|
||||
@@ -825,11 +837,6 @@ async function executeLeasedTask(
|
||||
}, leaseExtendIntervalMs);
|
||||
|
||||
try {
|
||||
if (mode === "manual" && task.kind === "publish") {
|
||||
await prepareManualPublishReview(taskRecord, abortController.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
const execution = await executeTaskAdapter(taskRecord, abortController.signal);
|
||||
const completed = await completeDesktopTask(taskRecord.id, {
|
||||
lease_token: leased.lease_token,
|
||||
@@ -895,112 +902,6 @@ async function executeLeasedTask(
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareManualPublishReview(task: RuntimeTaskRecord, signal: AbortSignal): Promise<void> {
|
||||
const preparation = await executeTaskAdapter(task, signal);
|
||||
if (preparation.status === "failed") {
|
||||
const completed = await completeDesktopTask(task.id, {
|
||||
lease_token: task.leaseToken as string,
|
||||
status: "failed",
|
||||
payload: preparation.payload,
|
||||
error: preparation.error,
|
||||
});
|
||||
|
||||
noteLeaseReleased("failed");
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing: task.routing,
|
||||
mode: task.mode,
|
||||
summary: preparation.summary,
|
||||
attemptId: null,
|
||||
leaseToken: null,
|
||||
});
|
||||
|
||||
recordActivity("danger", "Manual Review Prepare Failed", `${task.title} 无法进入人工审核态。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewUrl = preparation.reviewUrl ?? resolveTaskReviewUrl({
|
||||
...task,
|
||||
result: preparation.payload ?? task.result,
|
||||
});
|
||||
if (!reviewUrl) {
|
||||
const completed = await completeDesktopTask(task.id, {
|
||||
lease_token: task.leaseToken as string,
|
||||
status: "unknown",
|
||||
payload: preparation.payload,
|
||||
error: preparation.error ?? {
|
||||
code: "manual_review_url_missing",
|
||||
message: "manual publish review url is not available for current platform",
|
||||
},
|
||||
});
|
||||
|
||||
noteLeaseReleased("completed");
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing: task.routing,
|
||||
mode: task.mode,
|
||||
summary: "当前平台暂未提供可打开的审核页,任务回写为 unknown,等待后续对账。",
|
||||
attemptId: null,
|
||||
leaseToken: null,
|
||||
});
|
||||
|
||||
recordActivity("warn", "Manual Review Unavailable", `${task.title} 暂无可用审核页。`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewUrl) {
|
||||
try {
|
||||
await openTaskReviewWindow(task, reviewUrl);
|
||||
} catch (error) {
|
||||
recordActivity("warn", "Review Window Open Failed", errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
await parkLeasedTask(task, {
|
||||
summary:
|
||||
preparation.summary || "任务已进入人工审核态,等待当前客户端操作员确认。",
|
||||
payload: preparation.payload ?? null,
|
||||
error: preparation.error ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function parkLeasedTask(
|
||||
task: RuntimeTaskRecord,
|
||||
options: {
|
||||
summary: string;
|
||||
payload?: Record<string, JsonValue> | null;
|
||||
error?: Record<string, JsonValue> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!task.leaseToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.payload) {
|
||||
rememberTaskLocalResult(task.id, options.payload);
|
||||
}
|
||||
if (options.error) {
|
||||
rememberTaskLocalError(task.id, options.error);
|
||||
}
|
||||
|
||||
const parked = await parkDesktopTask(task.id, {
|
||||
lease_token: task.leaseToken,
|
||||
reason: "waiting_user",
|
||||
});
|
||||
|
||||
noteLeaseReleased("parked");
|
||||
upsertTaskFromInfo(parked, {
|
||||
routing: task.routing,
|
||||
summary: options.summary,
|
||||
attemptId: null,
|
||||
leaseToken: null,
|
||||
});
|
||||
|
||||
recordActivity("warn", "Task Parked", `${task.title} 已进入人工审核态。`);
|
||||
} catch (error) {
|
||||
recordActivity("danger", "Task Park Failed", `${task.title} 进入人工审核态失败:${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function extendActiveLease(taskId: string, leaseToken: string): Promise<void> {
|
||||
try {
|
||||
const task = await extendDesktopTask(taskId, { lease_token: leaseToken });
|
||||
@@ -1035,16 +936,18 @@ async function executeTaskAdapter(
|
||||
return buildScaffoldResult(task, payload, "publish adapter is not implemented yet");
|
||||
}
|
||||
|
||||
const viewHandle = acquireHotView(task.accountId || task.id);
|
||||
const viewHandle =
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: acquireHotView(task.accountId || task.id);
|
||||
|
||||
const result = await adapter.publish(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle.view,
|
||||
view: viewHandle?.view ?? null,
|
||||
signal,
|
||||
mode: task.mode,
|
||||
phase: "initial",
|
||||
article: await loadPublishArticle(task),
|
||||
reportProgress(stage: string) {
|
||||
@@ -1062,16 +965,18 @@ async function executeTaskAdapter(
|
||||
return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet");
|
||||
}
|
||||
|
||||
const viewHandle = acquireHotView(task.accountId || task.id);
|
||||
const viewHandle =
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: acquireHotView(task.accountId || task.id);
|
||||
|
||||
const result = await adapter.query(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle.view,
|
||||
view: viewHandle?.view ?? null,
|
||||
signal,
|
||||
mode: task.mode,
|
||||
phase: "initial",
|
||||
reportProgress(stage: string) {
|
||||
updateTaskProgress(task.id, `monitor adapter progress: ${stage}`);
|
||||
@@ -1107,7 +1012,7 @@ function buildScaffoldResult(
|
||||
|
||||
async function loadPublishArticle(
|
||||
task: RuntimeTaskRecord,
|
||||
): Promise<DesktopArticleContent & { publish_type: "publish" | "draft" }> {
|
||||
): Promise<DesktopArticleContent> {
|
||||
const articleId = resolveArticleId(task.payload);
|
||||
if (articleId === null) {
|
||||
throw new Error("desktop_publish_article_id_missing");
|
||||
@@ -1117,7 +1022,6 @@ async function loadPublishArticle(
|
||||
return {
|
||||
...article,
|
||||
title: article.title?.trim() || task.title,
|
||||
publish_type: task.mode === "manual" ? "draft" : "publish",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1147,109 +1051,6 @@ function toPositiveInt(value: JsonValue | null): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openTaskReviewWindow(task: RuntimeTaskRecord, reviewURL: string): Promise<void> {
|
||||
const sessionHandle = createSessionHandle(task.accountId || task.id);
|
||||
await openReviewWindow({
|
||||
taskId: task.id,
|
||||
title: `${task.title} · 审核`,
|
||||
url: reviewURL,
|
||||
session: sessionHandle.session,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTaskReviewUrl(task: RuntimeTaskRecord): string | null {
|
||||
const candidate =
|
||||
firstString(task.result?.external_manage_url) ??
|
||||
firstString(task.result?.review_url) ??
|
||||
firstString(task.payload.external_manage_url) ??
|
||||
firstString(task.payload.review_url);
|
||||
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (task.platform === "toutiaohao") {
|
||||
return "https://mp.toutiao.com/profile_v4/graphic/publish";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstString(value: JsonValue | undefined): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function buildManualResolutionPayload(
|
||||
task: RuntimeTaskRecord,
|
||||
status: "succeeded" | "failed" | "unknown",
|
||||
): {
|
||||
payload: Record<string, JsonValue>;
|
||||
error?: Record<string, JsonValue>;
|
||||
summary: string;
|
||||
} {
|
||||
const payload: Record<string, JsonValue> = {
|
||||
...(task.result ?? {}),
|
||||
manual_resolution: status,
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolved_by: "desktop_operator",
|
||||
};
|
||||
|
||||
if (status === "succeeded") {
|
||||
return {
|
||||
payload,
|
||||
summary: "人工审核已确认完成,任务回写为 succeeded。",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "unknown") {
|
||||
return {
|
||||
payload,
|
||||
error: {
|
||||
code: "manual_review_unconfirmed",
|
||||
message: "operator could not confirm final publish result",
|
||||
},
|
||||
summary: "人工审核后仍无法确认最终状态,任务回写为 unknown。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
payload,
|
||||
error: {
|
||||
code: "manual_review_rejected",
|
||||
message: "operator marked parked task as failed",
|
||||
},
|
||||
summary: "人工审核已拒绝该任务,任务回写为 failed。",
|
||||
};
|
||||
}
|
||||
|
||||
function rememberTaskLocalResult(taskId: string, payload: Record<string, JsonValue>): void {
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
state.tasks.set(taskId, {
|
||||
...existing,
|
||||
result: {
|
||||
...(existing.result ?? {}),
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rememberTaskLocalError(taskId: string, error: Record<string, JsonValue>): void {
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
state.tasks.set(taskId, {
|
||||
...existing,
|
||||
error: {
|
||||
...(existing.error ?? {}),
|
||||
...error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateTaskProgress(taskId: string, summary: string): void {
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (!existing) {
|
||||
@@ -1265,7 +1066,6 @@ function upsertTaskFromInfo(
|
||||
task: DesktopTaskInfo,
|
||||
options: {
|
||||
routing?: RuntimeTaskRouting;
|
||||
mode?: RuntimeTaskMode;
|
||||
summary?: string;
|
||||
attemptId?: string | null;
|
||||
leaseToken?: string | null;
|
||||
@@ -1273,7 +1073,6 @@ function upsertTaskFromInfo(
|
||||
): RuntimeTaskRecord {
|
||||
const existing = state.tasks.get(task.id);
|
||||
const payload = normalizeJsonObject(task.payload);
|
||||
const mode = options.mode ?? existing?.mode ?? resolveTaskMode(payload);
|
||||
const record: RuntimeTaskRecord = {
|
||||
id: task.id,
|
||||
jobId: task.job_id,
|
||||
@@ -1284,18 +1083,13 @@ function upsertTaskFromInfo(
|
||||
accountName: resolveAccountName(task.target_account_id, existing?.accountName),
|
||||
clientId: task.target_client_id,
|
||||
status: task.status,
|
||||
mode,
|
||||
routing: options.routing ?? existing?.routing ?? "db-recovery",
|
||||
leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null,
|
||||
updatedAt: parseTimestamp(task.updated_at) ?? Date.now(),
|
||||
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status, mode),
|
||||
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status),
|
||||
payload,
|
||||
error:
|
||||
normalizeJsonObjectOrNull(task.error)
|
||||
?? (task.status === "waiting_user" ? existing?.error ?? null : null),
|
||||
result:
|
||||
normalizeJsonObjectOrNull(task.result)
|
||||
?? (task.status === "waiting_user" ? existing?.result ?? null : null),
|
||||
error: normalizeJsonObjectOrNull(task.error),
|
||||
result: normalizeJsonObjectOrNull(task.result),
|
||||
attemptId:
|
||||
task.status === "in_progress"
|
||||
? options.attemptId ?? existing?.attemptId ?? task.active_attempt_id ?? null
|
||||
@@ -1422,20 +1216,14 @@ function defaultTaskTitle(kind: "publish" | "monitor", platform?: string): strin
|
||||
return `${kind === "publish" ? "发布任务" : "监控任务"}${platform ? ` · ${platform}` : ""}`;
|
||||
}
|
||||
|
||||
function resolveTaskMode(payload: Record<string, JsonValue>): RuntimeTaskMode {
|
||||
return payload.mode === "manual" ? "manual" : "auto";
|
||||
}
|
||||
|
||||
function summaryFromTaskStatus(status: RuntimeTaskStatus, mode: RuntimeTaskMode): string {
|
||||
function summaryFromTaskStatus(status: RuntimeTaskStatus): string {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "任务已排队,等待客户端领取租约。";
|
||||
case "in_progress":
|
||||
return "任务正在执行,结果回写需要携带有效 lease_token。";
|
||||
case "waiting_user":
|
||||
return "任务已 parked,等待原客户端上的人工确认。";
|
||||
case "succeeded":
|
||||
return mode === "manual" ? "任务已完成人工审核并成功提交。" : "任务执行成功。";
|
||||
return "任务执行成功。";
|
||||
case "failed":
|
||||
return "任务执行失败。";
|
||||
case "aborted":
|
||||
@@ -1453,14 +1241,12 @@ function summaryFromEventType(type: DesktopTaskEventMessage["type"], status: Run
|
||||
return "服务端已确认租约归属。";
|
||||
case "task_extended":
|
||||
return "任务租约已续期。";
|
||||
case "task_parked":
|
||||
return "任务已 parked 到 waiting_user。";
|
||||
case "task_canceled":
|
||||
return "任务已被取消。";
|
||||
case "task_reconciled":
|
||||
return "任务已完成人工对账并回写。";
|
||||
default:
|
||||
return summaryFromTaskStatus(status, "auto");
|
||||
return summaryFromTaskStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,39 +1278,6 @@ function normalizeJsonObjectOrNull(
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function normalizeUnknownResult(value: Record<string, unknown>): Record<string, JsonValue> {
|
||||
const output: Record<string, JsonValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = normalizeJsonValue(item);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeJsonValue(value: unknown): JsonValue {
|
||||
if (
|
||||
value === null
|
||||
|| typeof value === "string"
|
||||
|| typeof value === "number"
|
||||
|| typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeJsonValue(item));
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value) {
|
||||
const output: Record<string, JsonValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
output[key] = normalizeJsonValue(item);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toStructuredError(error: unknown): Record<string, JsonValue> {
|
||||
if (error instanceof ApiClientError) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user