8ea4a2ffff
Desktop Client Build / Resolve Build Metadata (push) Successful in 20s
Frontend CI / Frontend (push) Successful in 2m57s
Desktop Client Build / Build Desktop Client (push) Successful in 25m11s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 34s
Backend CI / Backend (push) Successful in 50m56s
4066 lines
125 KiB
TypeScript
4066 lines
125 KiB
TypeScript
import { cpus, hostname, totalmem } from 'node:os'
|
||
|
||
import { ApiClientError } from '@geo/http-client'
|
||
import type {
|
||
DesktopAccountHealthReport,
|
||
DesktopAccountInfo,
|
||
DesktopArticleContent,
|
||
DesktopClientInfo,
|
||
DesktopRuntimeSessionSyncRequest,
|
||
DesktopTaskEventMessage,
|
||
DesktopTaskInfo,
|
||
JsonValue,
|
||
LeaseDesktopTaskResponse,
|
||
MonitoringLeaseTask,
|
||
MonitoringSourceItem,
|
||
MonitoringTaskResultPayload,
|
||
} from '@geo/shared-types'
|
||
import { aiPlatformCatalog, getAIPlatformCatalogItem, isAIPlatformId } from '@geo/shared-types'
|
||
|
||
import {
|
||
buildAccountIdentityKey,
|
||
normalizeAccountPlatformUid,
|
||
sameAccountPlatformUid,
|
||
} from '../shared/account-identity'
|
||
import { normalizeDesktopApiBaseURL } from '../shared/api-base-url'
|
||
import { type PublishAccountIdentity, type PublishAccountProfile } from './account-binder'
|
||
import {
|
||
ensureAccountReady,
|
||
forgetTrackedAccountHealth,
|
||
getAccountHealthSnapshot,
|
||
getProjectedAccountHealth,
|
||
markTrackedAccountBound,
|
||
markTrackedAccountMissingPartition,
|
||
probeTrackedAccount,
|
||
reportAccountFailure,
|
||
syncTrackedAccounts,
|
||
} from './account-health'
|
||
import {
|
||
baijiahaoAdapter,
|
||
bilibiliAdapter,
|
||
dongchediAdapter,
|
||
getMonitorAdapter,
|
||
jianshuAdapter,
|
||
juejinAdapter,
|
||
qiehaoAdapter,
|
||
smzdmAdapter,
|
||
sohuhaoAdapter,
|
||
toutiaoAdapter,
|
||
wangyihaoAdapter,
|
||
weixinGzhAdapter,
|
||
zhihuAdapter,
|
||
zolAdapter,
|
||
type AdapterExecutionResult,
|
||
type MonitorAdapter,
|
||
type PublishAdapter,
|
||
} from './adapters'
|
||
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
|
||
import {
|
||
getLeaseManagerSnapshot,
|
||
noteLeaseExtended,
|
||
noteLeaseReleased,
|
||
setActiveLease,
|
||
} from './lease-manager'
|
||
import {
|
||
enqueueMonitorLeaseTask,
|
||
enqueueMonitorTaskFromEvent,
|
||
getMonitorSchedulerSnapshot,
|
||
initMonitorScheduler,
|
||
noteMonitorTaskActivated,
|
||
noteMonitorTaskCanceled,
|
||
noteMonitorTaskCompleted,
|
||
noteMonitorTaskLeaseMiss,
|
||
noteMonitorTaskLeased,
|
||
selectNextMonitorTask,
|
||
} from './monitor-scheduler'
|
||
import { retainHiddenPlaywrightPage, startHiddenPlaywrightReaper } from './playwright-cdp'
|
||
import { getProcessMetricsSnapshot } from './process-metrics'
|
||
import {
|
||
enqueuePublishTask as enqueuePublishSchedulerTask,
|
||
getPublishSchedulerSnapshot,
|
||
initPublishScheduler,
|
||
notePublishTaskActivated,
|
||
notePublishTaskCanceled,
|
||
notePublishTaskCompleted,
|
||
notePublishTaskLeaseMiss,
|
||
selectNextPublishTask,
|
||
} from './publish-scheduler'
|
||
import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events'
|
||
import {
|
||
initScheduler,
|
||
noteSchedulerAccountsSync,
|
||
noteSchedulerHeartbeat,
|
||
noteSchedulerPull,
|
||
noteSchedulerSseEvent,
|
||
noteSchedulerTaskFinished,
|
||
setSchedulerCurrentTask,
|
||
setSchedulerError,
|
||
setSchedulerNextHeartbeat,
|
||
setSchedulerNextPull,
|
||
setSchedulerPhase,
|
||
setSchedulerQueueDepth,
|
||
} from './scheduler'
|
||
import {
|
||
clearSessionHandle,
|
||
createSessionHandle,
|
||
getPersistedPartition,
|
||
getSessionHandle,
|
||
} from './session-registry'
|
||
import {
|
||
cancelDesktopTask,
|
||
cancelMonitoringTask,
|
||
completeDesktopTask,
|
||
configureTransport,
|
||
deleteDesktopAccount,
|
||
extendDesktopTask,
|
||
getDesktopArticleContent,
|
||
heartbeatDesktopClient,
|
||
leaseDesktopTask,
|
||
leaseMonitoringTasks,
|
||
listDesktopAccounts,
|
||
markDesktopTaskPublishSubmitStarted,
|
||
noteTransportHeartbeat,
|
||
noteTransportPull,
|
||
offlineDesktopClient,
|
||
reportDesktopAccountHealth,
|
||
resumeMonitoringTasks,
|
||
revokeDesktopClient,
|
||
setTransportAuthState,
|
||
setTransportDispatchState,
|
||
skipMonitoringTask,
|
||
submitMonitoringTaskResult,
|
||
upsertDesktopAccount,
|
||
} from './transport/api-client'
|
||
import { DispatchWsClient } from './transport/dispatch-ws-client'
|
||
import { releaseHotView, retainHotView } from './view-pool'
|
||
|
||
type RuntimeTone = 'info' | 'success' | 'warn' | 'danger'
|
||
type RuntimeTaskStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'unknown' | 'aborted'
|
||
type RuntimeTaskRouting = 'rabbitmq-primary' | 'db-recovery'
|
||
type MonitorExecutionPhase =
|
||
| 'PREPARING'
|
||
| 'NAVIGATING'
|
||
| 'AUTHENTICATING'
|
||
| 'INPUTTING'
|
||
| 'SUBMITTING'
|
||
| 'WAITING'
|
||
| 'PARSING'
|
||
| 'POSTING'
|
||
|
||
const heartbeatIntervalMs = 15_000
|
||
const pullIntervalMs = 60_000
|
||
const pumpWatchdogIntervalMs = 15_000
|
||
const leaseExtendIntervalMs = 60_000
|
||
const defaultPublishTaskTimeoutMs = 5 * 60_000
|
||
const publishTaskStaleProgressMs = 2 * 60_000
|
||
const publishLongRunningNotificationMs = 15 * 60_000
|
||
const maxActivityItems = 60
|
||
const monitorBusinessTimeZone = 'Asia/Shanghai'
|
||
const authRequiredMonitorPlatforms = new Set(aiPlatformCatalog.map((platform) => platform.id))
|
||
const publishReservedSlots = 1
|
||
const minimumForegroundTotalConcurrency = 2
|
||
const maximumRuntimeTotalConcurrency = 4
|
||
const BAIJIAHAO_RISK_CONTROL_PROMPT = '您触发平台风控,请手动发稿一篇并完成验证,方可继续使用'
|
||
const accountHealthReportDebounceMs = 1_000
|
||
const accountHealthReportMaxBatchSize = 100
|
||
|
||
interface RuntimeTaskRecord {
|
||
id: string
|
||
jobId: string
|
||
title: string
|
||
kind: 'publish' | 'monitor'
|
||
platform: string
|
||
accountId: string
|
||
accountName: string
|
||
clientId: string
|
||
status: RuntimeTaskStatus
|
||
routing: RuntimeTaskRouting
|
||
leaseExpiresAt: number | null
|
||
startedAt: number | null
|
||
updatedAt: number
|
||
summary: string
|
||
payload: Record<string, JsonValue>
|
||
error: Record<string, JsonValue> | null
|
||
result: Record<string, JsonValue> | null
|
||
attemptId: string | null
|
||
leaseToken: string | null
|
||
}
|
||
|
||
export interface RuntimeControllerActivity {
|
||
id: string
|
||
severity: RuntimeTone
|
||
title: string
|
||
detail: string
|
||
at: number
|
||
}
|
||
|
||
export interface RuntimeControllerSnapshot {
|
||
session: DesktopRuntimeSessionSyncRequest | null
|
||
client: DesktopClientInfo | null
|
||
running: boolean
|
||
dispatchConnected: boolean
|
||
queueDepth: number
|
||
accounts: DesktopAccountInfo[]
|
||
accountProfiles: Record<string, PublishAccountProfile>
|
||
tasks: Array<Omit<RuntimeTaskRecord, 'leaseToken'>>
|
||
activity: RuntimeControllerActivity[]
|
||
lastHeartbeatAt: number
|
||
lastHeartbeatStatus: 'idle' | 'success' | 'failed'
|
||
lastPullAt: number
|
||
lastPullStatus: 'idle' | 'success' | 'failed'
|
||
lastAccountsSyncAt: number
|
||
lastServerTime: string | null
|
||
onlineClientCount: number | null
|
||
lastError: string | null
|
||
}
|
||
|
||
interface PendingTaskRequest {
|
||
taskId: string
|
||
routing: RuntimeTaskRouting
|
||
platform?: string
|
||
source?: 'desktop_task' | 'monitoring_lease'
|
||
}
|
||
|
||
interface ActiveRuntimeExecution {
|
||
taskId: string
|
||
kind: 'publish' | 'monitor'
|
||
platform: string
|
||
accountId: string
|
||
abortController: AbortController
|
||
executionPhase: MonitorExecutionPhase | null
|
||
interruptRequested: boolean
|
||
interruptGeneration: number
|
||
interruptReason: string | null
|
||
lastSafePointAt: number | null
|
||
startedAt: number
|
||
lastProgressAt: number
|
||
lastProgressSummary: string | null
|
||
deadlineAt: number | null
|
||
deadlineHandle: ReturnType<typeof setTimeout> | null
|
||
longRunningNotifiedAt: number | null
|
||
// Set the moment a publish adapter enters its irreversible submit/save_draft stage. Once set,
|
||
// an interrupted publish is completed as 'unknown' (manual reconcile) instead of 'failed', so a
|
||
// possibly-already-published article is never silently re-posted by a retry.
|
||
submitStartedAt: number | null
|
||
}
|
||
|
||
interface RuntimeState {
|
||
session: DesktopRuntimeSessionSyncRequest | null
|
||
client: DesktopClientInfo | null
|
||
running: boolean
|
||
tasks: Map<string, RuntimeTaskRecord>
|
||
accounts: DesktopAccountInfo[]
|
||
accountProfiles: Map<string, PublishAccountProfile>
|
||
activity: RuntimeControllerActivity[]
|
||
activeExecutions: Map<string, ActiveRuntimeExecution>
|
||
leaseInFlightKinds: Set<'publish' | 'monitor'>
|
||
publishFallbackBackoffUntil: number
|
||
heartbeatTimer: ReturnType<typeof setInterval> | null
|
||
pullTimer: ReturnType<typeof setInterval> | null
|
||
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
|
||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
|
||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
|
||
accountHealthReportBadAccounts: Set<string>
|
||
accountRemoteHealth: Map<string, DesktopAccountInfo['health']>
|
||
dispatchWsClient: DispatchWsClient | null
|
||
dispatchWsConnected: boolean
|
||
lastHeartbeatAt: number
|
||
lastHeartbeatStatus: 'idle' | 'success' | 'failed'
|
||
lastPullAt: number
|
||
lastPullStatus: 'idle' | 'success' | 'failed'
|
||
lastAccountsSyncAt: number
|
||
lastServerTime: string | null
|
||
onlineClientCount: number | null
|
||
lastError: string | null
|
||
activitySeq: number
|
||
}
|
||
|
||
const state: RuntimeState = {
|
||
session: null,
|
||
client: null,
|
||
running: false,
|
||
tasks: new Map<string, RuntimeTaskRecord>(),
|
||
accounts: [],
|
||
accountProfiles: new Map<string, PublishAccountProfile>(),
|
||
activity: [],
|
||
activeExecutions: new Map<string, ActiveRuntimeExecution>(),
|
||
leaseInFlightKinds: new Set<'publish' | 'monitor'>(),
|
||
publishFallbackBackoffUntil: 0,
|
||
heartbeatTimer: null,
|
||
pullTimer: null,
|
||
pumpWatchdogTimer: null,
|
||
accountHealthReportTimer: null,
|
||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||
accountHealthReportBadAccounts: new Set<string>(),
|
||
accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(),
|
||
dispatchWsClient: null,
|
||
dispatchWsConnected: false,
|
||
lastHeartbeatAt: 0,
|
||
lastHeartbeatStatus: 'idle',
|
||
lastPullAt: 0,
|
||
lastPullStatus: 'idle',
|
||
lastAccountsSyncAt: 0,
|
||
lastServerTime: null,
|
||
onlineClientCount: null,
|
||
lastError: null,
|
||
activitySeq: 0,
|
||
}
|
||
let accountHealthReportBridgeUnsubscribe: (() => void) | null = null
|
||
|
||
function accountIdentityFromDesktopAccount(account: DesktopAccountInfo): PublishAccountIdentity {
|
||
return {
|
||
id: account.id,
|
||
platform: account.platform,
|
||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||
displayName: account.display_name,
|
||
}
|
||
}
|
||
|
||
function accountIdentityFromTask(task: RuntimeTaskRecord): PublishAccountIdentity | null {
|
||
if (!task.accountId) {
|
||
return null
|
||
}
|
||
|
||
const matched = state.accounts.find((account) => account.id === task.accountId) ?? null
|
||
if (matched) {
|
||
return accountIdentityFromDesktopAccount(matched)
|
||
}
|
||
|
||
return {
|
||
id: task.accountId,
|
||
platform: task.platform,
|
||
platformUid: '',
|
||
displayName: task.accountName || '待同步账号',
|
||
}
|
||
}
|
||
|
||
function isDefinitiveAuthState(authState: string): boolean {
|
||
return ['active', 'expired', 'challenge_required', 'revoked'].includes(authState)
|
||
}
|
||
|
||
function hasLocalAccountSession(accountId: string): boolean {
|
||
return Boolean(getSessionHandle(accountId) || getPersistedPartition(accountId))
|
||
}
|
||
|
||
function isAccountOwnedByCurrentClient(account: DesktopAccountInfo): boolean {
|
||
return Boolean(account.client_id && account.client_id === state.client?.id)
|
||
}
|
||
|
||
function hasLocalAccountAuthorization(account: DesktopAccountInfo): boolean {
|
||
return isAccountOwnedByCurrentClient(account) && hasLocalAccountSession(account.id)
|
||
}
|
||
|
||
function accountRequiresLocalAuthorization(account: DesktopAccountInfo): boolean {
|
||
return Boolean(account.client_id)
|
||
}
|
||
|
||
function shouldReportAccountPresence(account: DesktopAccountInfo): boolean {
|
||
return hasLocalAccountSession(account.id) && isAccountOwnedByCurrentClient(account)
|
||
}
|
||
|
||
function buildRuntimeAccountDedupeKey(account: DesktopAccountInfo): string {
|
||
if (isAIPlatformId(account.platform)) {
|
||
return `ai-platform:${account.platform}`
|
||
}
|
||
|
||
return buildAccountIdentityKey({
|
||
platform: account.platform,
|
||
platformUid: account.platform_uid,
|
||
displayName: account.display_name,
|
||
})
|
||
}
|
||
|
||
function accountHealthRank(health: DesktopAccountInfo['health']): number {
|
||
switch (health) {
|
||
case 'live':
|
||
return 4
|
||
case 'risk':
|
||
return 3
|
||
case 'captcha':
|
||
return 2
|
||
case 'expired':
|
||
return 1
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
function accountTimestamp(value: string | null | undefined): number {
|
||
return parseTimestamp(value) ?? 0
|
||
}
|
||
|
||
function shouldPreferRuntimeAccount(
|
||
candidate: DesktopAccountInfo,
|
||
current: DesktopAccountInfo,
|
||
): boolean {
|
||
const candidateHealthRank = accountHealthRank(candidate.health)
|
||
const currentHealthRank = accountHealthRank(current.health)
|
||
if (candidateHealthRank !== currentHealthRank) {
|
||
return candidateHealthRank > currentHealthRank
|
||
}
|
||
|
||
const candidateVerifiedAt = accountTimestamp(candidate.verified_at)
|
||
const currentVerifiedAt = accountTimestamp(current.verified_at)
|
||
if (candidateVerifiedAt !== currentVerifiedAt) {
|
||
return candidateVerifiedAt > currentVerifiedAt
|
||
}
|
||
|
||
return candidate.sync_version > current.sync_version
|
||
}
|
||
|
||
function runtimePlatformLabel(platform: string): string {
|
||
return getAIPlatformCatalogItem(platform)?.label ?? platform
|
||
}
|
||
|
||
function accountActionRequiredSummary(
|
||
platform: string,
|
||
authReason: string | null | undefined,
|
||
): string {
|
||
const label = runtimePlatformLabel(platform)
|
||
if (platform === 'baijiahao') {
|
||
return BAIJIAHAO_RISK_CONTROL_PROMPT
|
||
}
|
||
if (authReason === 'risk_control') {
|
||
return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。`
|
||
}
|
||
return `${label} 账号需要完成人机验证后才能继续执行。`
|
||
}
|
||
|
||
function markAuthorizationFailureNonRetryable(
|
||
error: Record<string, JsonValue>,
|
||
category: 'ai_platform_authorization' | 'media_account_authorization',
|
||
): Record<string, JsonValue> {
|
||
return {
|
||
...error,
|
||
retryable: false,
|
||
non_retryable: true,
|
||
failure_category: category,
|
||
}
|
||
}
|
||
|
||
function isAuthorizationFailureCode(code: string | null | undefined): boolean {
|
||
if (!code) {
|
||
return false
|
||
}
|
||
const normalized = code.trim().toLowerCase()
|
||
return (
|
||
normalized === 'desktop_account_auth_expired' ||
|
||
normalized === 'desktop_account_challenge_required' ||
|
||
normalized === 'desktop_account_risk_control' ||
|
||
normalized.endsWith('_not_logged_in') ||
|
||
normalized.endsWith('_login_required') ||
|
||
normalized.endsWith('_login_expired') ||
|
||
normalized.endsWith('_challenge_required')
|
||
)
|
||
}
|
||
|
||
function isAuthorizationFailureMessage(message: string | null | undefined): boolean {
|
||
if (!message) {
|
||
return false
|
||
}
|
||
const normalized = message.trim().toLowerCase()
|
||
return (
|
||
normalized.includes('login required') ||
|
||
normalized.includes('login expired') ||
|
||
normalized.includes('not logged in') ||
|
||
normalized.includes('unauthorized') ||
|
||
normalized.includes('登录态失效') ||
|
||
normalized.includes('登录态已失效') ||
|
||
normalized.includes('登录已失效') ||
|
||
normalized.includes('请重新登录') ||
|
||
normalized.includes('请先登录') ||
|
||
normalized.includes('未登录')
|
||
)
|
||
}
|
||
|
||
function isAuthorizationFailureCategory(category: string | null | undefined): boolean {
|
||
return category === 'ai_platform_authorization' || category === 'media_account_authorization'
|
||
}
|
||
|
||
function isNonRetryableAuthorizationFailure(
|
||
task: RuntimeTaskRecord,
|
||
result: AdapterExecutionResult,
|
||
): boolean {
|
||
if (result.status !== 'failed') {
|
||
return false
|
||
}
|
||
|
||
const code = typeof result.error?.code === 'string' ? result.error.code : null
|
||
const message = typeof result.error?.message === 'string' ? result.error.message : null
|
||
const category =
|
||
typeof result.error?.failure_category === 'string' ? result.error.failure_category : null
|
||
if (isAuthorizationFailureCategory(category)) {
|
||
return true
|
||
}
|
||
if (!isAuthorizationFailureCode(code) && !isAuthorizationFailureMessage(message)) {
|
||
return false
|
||
}
|
||
|
||
return task.kind === 'publish' || isAIPlatformId(task.platform)
|
||
}
|
||
|
||
function withAuthorizationRetryPolicy(
|
||
task: RuntimeTaskRecord,
|
||
result: AdapterExecutionResult,
|
||
): AdapterExecutionResult {
|
||
if (!isNonRetryableAuthorizationFailure(task, result)) {
|
||
return result
|
||
}
|
||
|
||
const category = isAIPlatformId(task.platform)
|
||
? 'ai_platform_authorization'
|
||
: 'media_account_authorization'
|
||
const baseError = result.error ?? {
|
||
code: 'desktop_account_authorization_failed',
|
||
message: result.summary,
|
||
}
|
||
return {
|
||
...result,
|
||
error: markAuthorizationFailureNonRetryable(baseError, category),
|
||
}
|
||
}
|
||
|
||
async function resolveAuthorizationPreflightBlock(
|
||
task: RuntimeTaskRecord,
|
||
): Promise<AdapterExecutionResult | null> {
|
||
const accountIdentity = accountIdentityFromTask(task)
|
||
if (!accountIdentity || !shouldEnforceActiveAccount(task)) {
|
||
return null
|
||
}
|
||
|
||
const previousSnapshot = getAccountHealthSnapshot(accountIdentity.id)
|
||
const readiness = await ensureAccountReady(accountIdentity)
|
||
if (readiness.authState === 'active') {
|
||
return null
|
||
}
|
||
|
||
const result = withAuthorizationRetryPolicy(task, buildAccountBlockedResult(task, readiness))
|
||
maybeRecordAccountAccessAlert(task, accountIdentity, previousSnapshot, readiness)
|
||
enqueueAccountHealthReport(accountIdentity.id)
|
||
recordAuthorizationPreflightBlock(task, result)
|
||
return result
|
||
}
|
||
|
||
function recordAuthorizationPreflightBlock(
|
||
task: RuntimeTaskRecord,
|
||
result: AdapterExecutionResult,
|
||
): void {
|
||
if (!isNonRetryableAuthorizationFailure(task, result)) {
|
||
return
|
||
}
|
||
|
||
const scope = task.kind === 'monitor' && isAIPlatformId(task.platform) ? '同模型' : '同账号'
|
||
recordActivity(
|
||
'warn',
|
||
'授权失效,任务未执行',
|
||
`${runtimePlatformLabel(task.platform)} ${scope}授权不是 active,${task.title} 已直接回写 non-retryable 授权失败。`,
|
||
)
|
||
}
|
||
|
||
function maybeRecordAccountAccessAlert(
|
||
task: RuntimeTaskRecord,
|
||
accountIdentity: PublishAccountIdentity | null,
|
||
previous: ReturnType<typeof getAccountHealthSnapshot>,
|
||
next: ReturnType<typeof getAccountHealthSnapshot>,
|
||
): void {
|
||
if (!accountIdentity || !next) {
|
||
return
|
||
}
|
||
|
||
if (previous?.authState === next.authState && previous?.authReason === next.authReason) {
|
||
return
|
||
}
|
||
|
||
if (next.authState === 'challenge_required' && next.authReason === 'risk_control') {
|
||
if (accountIdentity.platform === 'baijiahao') {
|
||
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
||
return
|
||
}
|
||
recordActivity(
|
||
'warn',
|
||
`${runtimePlatformLabel(accountIdentity.platform)} 触发风控`,
|
||
`${accountIdentity.displayName || task.accountName || '当前账号'} 已被平台限制,请打开平台处理验证或等待限制解除后再刷新状态。`,
|
||
)
|
||
return
|
||
}
|
||
|
||
if (next.authState === 'challenge_required') {
|
||
if (accountIdentity.platform === 'baijiahao') {
|
||
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
||
return
|
||
}
|
||
recordActivity(
|
||
'warn',
|
||
`${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`,
|
||
`${accountIdentity.displayName || task.accountName || '当前账号'} 需要打开平台完成人工验证后再继续执行任务。`,
|
||
)
|
||
}
|
||
}
|
||
|
||
function isoFromTimestamp(value: number | null): string | null {
|
||
return value ? new Date(value).toISOString() : null
|
||
}
|
||
|
||
export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null): void {
|
||
const normalized = normalizeSession(next)
|
||
const changed = !sameSession(state.session, normalized)
|
||
|
||
state.session = normalized
|
||
state.client = normalized?.desktop_client ?? null
|
||
|
||
if (!canRunLive(normalized)) {
|
||
stopRuntime()
|
||
clearRuntimeState()
|
||
configureTransport(null)
|
||
setTransportAuthState('pending')
|
||
return
|
||
}
|
||
|
||
configureTransport(normalized)
|
||
setTransportAuthState('registered')
|
||
|
||
if (changed) {
|
||
stopRuntime()
|
||
clearRuntimeState()
|
||
recordActivity(
|
||
'info',
|
||
'任务消费端已就绪',
|
||
`客户端 ${normalized.desktop_client?.device_name ?? normalized.desktop_client?.id ?? '未知客户端'} 已准备消费桌面任务。`,
|
||
)
|
||
}
|
||
|
||
if (!state.running) {
|
||
startRuntime()
|
||
}
|
||
}
|
||
|
||
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,
|
||
client: state.client ? { ...state.client } : null,
|
||
running: state.running,
|
||
dispatchConnected: state.dispatchWsConnected,
|
||
queueDepth: localQueueDepth(),
|
||
accounts: state.accounts.map((item) => ({ ...item })),
|
||
accountProfiles: Object.fromEntries(
|
||
[...state.accountProfiles.entries()].map(([accountId, profile]) => [
|
||
accountId,
|
||
{ ...profile },
|
||
]),
|
||
),
|
||
tasks: [...state.tasks.values()]
|
||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||
.map(({ leaseToken: _leaseToken, ...task }) => ({
|
||
...task,
|
||
startedAt: state.activeExecutions.get(task.id)?.startedAt ?? task.startedAt,
|
||
})),
|
||
activity: [...state.activity],
|
||
lastHeartbeatAt: state.lastHeartbeatAt,
|
||
lastHeartbeatStatus: state.lastHeartbeatStatus,
|
||
lastPullAt: state.lastPullAt,
|
||
lastPullStatus: state.lastPullStatus,
|
||
lastAccountsSyncAt: state.lastAccountsSyncAt,
|
||
lastServerTime: state.lastServerTime,
|
||
onlineClientCount: state.onlineClientCount,
|
||
lastError: state.lastError,
|
||
}
|
||
}
|
||
|
||
function startRuntime(): void {
|
||
if (!canRunLive(state.session) || state.running) {
|
||
return
|
||
}
|
||
|
||
initScheduler()
|
||
initMonitorScheduler()
|
||
initPublishScheduler()
|
||
startHiddenPlaywrightReaper()
|
||
ensureAccountHealthReportBridge()
|
||
state.running = true
|
||
setSchedulerPhase('running')
|
||
setSchedulerError(null)
|
||
recordActivity('success', '调度节点已启动', '已启动 WebSocket 分发、心跳上报和兜底拉取。')
|
||
|
||
connectDispatchWs()
|
||
scheduleHeartbeatLoop()
|
||
schedulePullLoop()
|
||
schedulePumpWatchdogLoop()
|
||
|
||
void sendHeartbeat('startup')
|
||
void syncAccounts('startup')
|
||
void resumeLeasedMonitoringTasks('startup')
|
||
pumpExecutionLoop()
|
||
}
|
||
|
||
function ensureAccountHealthReportBridge(): void {
|
||
if (accountHealthReportBridgeUnsubscribe) {
|
||
return
|
||
}
|
||
|
||
accountHealthReportBridgeUnsubscribe = onRuntimeInvalidated((event) => {
|
||
if (event.reason !== 'account-health' || !event.accountId) {
|
||
return
|
||
}
|
||
enqueueAccountHealthReport(event.accountId)
|
||
})
|
||
}
|
||
|
||
function stopRuntime(): void {
|
||
state.running = false
|
||
state.leaseInFlightKinds.clear()
|
||
for (const execution of state.activeExecutions.values()) {
|
||
if (execution.deadlineHandle) {
|
||
clearTimeout(execution.deadlineHandle)
|
||
execution.deadlineHandle = null
|
||
}
|
||
execution.abortController.abort()
|
||
}
|
||
state.activeExecutions.clear()
|
||
|
||
if (state.heartbeatTimer) {
|
||
clearInterval(state.heartbeatTimer)
|
||
state.heartbeatTimer = null
|
||
}
|
||
if (state.pullTimer) {
|
||
clearInterval(state.pullTimer)
|
||
state.pullTimer = null
|
||
}
|
||
if (state.pumpWatchdogTimer) {
|
||
clearInterval(state.pumpWatchdogTimer)
|
||
state.pumpWatchdogTimer = null
|
||
}
|
||
if (state.accountHealthReportTimer) {
|
||
clearTimeout(state.accountHealthReportTimer)
|
||
state.accountHealthReportTimer = null
|
||
}
|
||
if (state.dispatchWsClient) {
|
||
state.dispatchWsClient.stop()
|
||
state.dispatchWsClient = null
|
||
}
|
||
state.dispatchWsConnected = false
|
||
setTransportDispatchState('idle')
|
||
|
||
setSchedulerPhase('idle')
|
||
setSchedulerCurrentTask(null)
|
||
setSchedulerQueueDepth(localQueueDepth())
|
||
setSchedulerNextHeartbeat(null)
|
||
setSchedulerNextPull(null)
|
||
|
||
if (getLeaseManagerSnapshot().activeTaskId) {
|
||
noteLeaseReleased('cleared')
|
||
}
|
||
}
|
||
|
||
function clearRuntimeState(): void {
|
||
initPublishScheduler()
|
||
state.tasks.clear()
|
||
state.accounts = []
|
||
state.accountProfiles.clear()
|
||
state.accountHealthReportQueue.clear()
|
||
state.accountHealthReportBadAccounts.clear()
|
||
state.accountRemoteHealth.clear()
|
||
state.activeExecutions.clear()
|
||
state.lastHeartbeatAt = 0
|
||
state.lastHeartbeatStatus = 'idle'
|
||
state.lastPullAt = 0
|
||
state.lastPullStatus = 'idle'
|
||
state.lastAccountsSyncAt = 0
|
||
state.lastServerTime = null
|
||
state.onlineClientCount = null
|
||
state.lastError = null
|
||
state.publishFallbackBackoffUntil = 0
|
||
setSchedulerQueueDepth(localQueueDepth())
|
||
}
|
||
|
||
function scheduleHeartbeatLoop(): void {
|
||
if (state.heartbeatTimer) {
|
||
clearInterval(state.heartbeatTimer)
|
||
}
|
||
|
||
setSchedulerNextHeartbeat(Date.now() + heartbeatIntervalMs)
|
||
state.heartbeatTimer = setInterval(() => {
|
||
setSchedulerNextHeartbeat(Date.now() + heartbeatIntervalMs)
|
||
void sendHeartbeat('timer')
|
||
}, heartbeatIntervalMs)
|
||
}
|
||
|
||
function schedulePullLoop(): void {
|
||
if (state.pullTimer) {
|
||
clearInterval(state.pullTimer)
|
||
}
|
||
|
||
setSchedulerNextPull(Date.now() + pullIntervalMs)
|
||
state.pullTimer = setInterval(() => {
|
||
setSchedulerNextPull(Date.now() + pullIntervalMs)
|
||
pumpExecutionLoop()
|
||
}, pullIntervalMs)
|
||
}
|
||
|
||
function schedulePumpWatchdogLoop(): void {
|
||
if (state.pumpWatchdogTimer) {
|
||
clearInterval(state.pumpWatchdogTimer)
|
||
}
|
||
|
||
state.pumpWatchdogTimer = setInterval(() => {
|
||
notifyLongRunningPublishTasks()
|
||
pumpExecutionLoop()
|
||
}, pumpWatchdogIntervalMs)
|
||
}
|
||
|
||
function connectDispatchWs(): void {
|
||
if (!canRunLive(state.session)) {
|
||
return
|
||
}
|
||
|
||
state.dispatchWsClient?.stop()
|
||
setTransportDispatchState('connecting')
|
||
|
||
const wsUrl = buildApiUrl(state.session.api_base_url, '/api/desktop/dispatch')
|
||
.replace(/^http:\/\//, 'ws://')
|
||
.replace(/^https:\/\//, 'wss://')
|
||
|
||
const client = new DispatchWsClient({
|
||
url: wsUrl,
|
||
headers: {
|
||
Authorization: `Bearer ${state.session.client_token}`,
|
||
},
|
||
})
|
||
let hasConnectedOnce = false
|
||
|
||
client.on('open', () => {
|
||
state.dispatchWsConnected = true
|
||
setTransportDispatchState('streaming')
|
||
setSchedulerError(null)
|
||
recordActivity('success', '任务分发通道已连接', '统一 WebSocket 分发通道已就绪。')
|
||
void pullNextTask('monitor')
|
||
if (hasConnectedOnce) {
|
||
void resumeLeasedMonitoringTasks('reconnect')
|
||
}
|
||
hasConnectedOnce = true
|
||
})
|
||
|
||
client.on('connected', (payload) => {
|
||
state.dispatchWsConnected = true
|
||
setTransportDispatchState('streaming')
|
||
if (isConnectedEvent(payload)) {
|
||
state.lastServerTime = payload.server_time
|
||
}
|
||
})
|
||
|
||
const dispatchTaskHandler = (payload: unknown) => {
|
||
state.dispatchWsConnected = true
|
||
noteSchedulerSseEvent()
|
||
|
||
if (isMonitoringTaskControlEvent(payload)) {
|
||
void handleMonitoringTaskControl(payload)
|
||
return
|
||
}
|
||
if (isMonitoringDispatchSignal(payload)) {
|
||
void handleMonitoringDispatchSignal(payload)
|
||
return
|
||
}
|
||
if (!isDesktopTaskEvent(payload)) {
|
||
return
|
||
}
|
||
handleTaskEvent(payload)
|
||
}
|
||
|
||
client.on('task_available', dispatchTaskHandler)
|
||
client.on('task_leased', dispatchTaskHandler)
|
||
client.on('task_extended', dispatchTaskHandler)
|
||
client.on('task_completed', dispatchTaskHandler)
|
||
client.on('task_canceled', dispatchTaskHandler)
|
||
client.on('task_reconciled', dispatchTaskHandler)
|
||
client.on('task_control', dispatchTaskHandler)
|
||
|
||
client.on('error', (payload) => {
|
||
state.dispatchWsConnected = false
|
||
setTransportDispatchState('connecting')
|
||
const message = errorMessage(payload)
|
||
state.lastError = message
|
||
setSchedulerError(message)
|
||
recordActivity('warn', '任务分发通道异常', message)
|
||
})
|
||
|
||
client.on('close', () => {
|
||
state.dispatchWsConnected = false
|
||
setTransportDispatchState(state.running ? 'connecting' : 'idle')
|
||
})
|
||
|
||
client.on('reconnect', (payload) => {
|
||
if (!isReconnectPayload(payload)) {
|
||
return
|
||
}
|
||
setTransportDispatchState('connecting')
|
||
recordActivity('info', '任务分发通道退避等待', `将在 ${payload.delayMs}ms 后重连 WebSocket。`)
|
||
})
|
||
|
||
state.dispatchWsClient = client
|
||
client.start()
|
||
}
|
||
|
||
function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||
if (event.type === 'task_control' || event.signal_only || !event.task_id.trim()) {
|
||
return
|
||
}
|
||
|
||
const existing = state.tasks.get(event.task_id)
|
||
const eventUpdatedAt = parseTimestamp(event.updated_at) ?? Date.now()
|
||
if (existing && eventUpdatedAt < existing.updatedAt) {
|
||
return
|
||
}
|
||
|
||
const next: RuntimeTaskRecord = {
|
||
id: event.task_id,
|
||
jobId: event.job_id,
|
||
title: existing?.title ?? defaultTaskTitle(event.kind, event.platform || existing?.platform),
|
||
kind: event.kind,
|
||
platform:
|
||
event.platform || existing?.platform || inferPlatformFromAccount(event.target_account_id),
|
||
accountId: event.target_account_id || existing?.accountId || '',
|
||
accountName: existing?.accountName ?? '待同步账号',
|
||
clientId: event.target_client_id,
|
||
status: event.status,
|
||
routing: existing?.routing ?? 'rabbitmq-primary',
|
||
leaseExpiresAt: event.status === 'in_progress' ? (existing?.leaseExpiresAt ?? null) : null,
|
||
startedAt: event.status === 'in_progress' ? (existing?.startedAt ?? eventUpdatedAt) : null,
|
||
updatedAt: eventUpdatedAt,
|
||
summary: summaryFromEventType(event.type, event.status),
|
||
payload: existing?.payload ?? {},
|
||
error: existing?.error ?? null,
|
||
result: existing?.result ?? null,
|
||
attemptId: event.status === 'in_progress' ? (existing?.attemptId ?? null) : null,
|
||
leaseToken: event.status === 'in_progress' ? (existing?.leaseToken ?? null) : null,
|
||
}
|
||
|
||
state.tasks.set(event.task_id, next)
|
||
|
||
if (event.kind === 'monitor') {
|
||
if (event.type === 'task_available') {
|
||
enqueueMonitorTaskFromEvent(event, 'rabbitmq-primary')
|
||
}
|
||
if (
|
||
event.type === 'task_completed' ||
|
||
event.type === 'task_reconciled' ||
|
||
event.type === 'task_canceled'
|
||
) {
|
||
noteMonitorTaskCanceled(event.task_id)
|
||
}
|
||
}
|
||
|
||
if (
|
||
event.kind === 'publish' &&
|
||
(event.type === 'task_completed' ||
|
||
event.type === 'task_reconciled' ||
|
||
event.type === 'task_canceled')
|
||
) {
|
||
notePublishTaskCanceled(event.task_id)
|
||
}
|
||
|
||
if (event.type === 'task_available') {
|
||
if (event.kind === 'publish') {
|
||
enqueuePublishTask(next.id, next.platform, 'rabbitmq-primary')
|
||
}
|
||
pumpExecutionLoop()
|
||
}
|
||
}
|
||
|
||
async function handleMonitoringDispatchSignal(event: DesktopTaskEventMessage): Promise<void> {
|
||
if (event.target_client_id !== currentClientID()) {
|
||
return
|
||
}
|
||
|
||
if ((event.lane ?? '').trim() === 'high') {
|
||
recordActivity(
|
||
'info',
|
||
'收到监控高优先级信号',
|
||
'客户端将按平台隔离和并发上限,优先领取 collect-now 桌面监控任务。',
|
||
)
|
||
}
|
||
|
||
if (event.task_id.trim()) {
|
||
enqueueMonitorTaskFromEvent(event, 'rabbitmq-primary')
|
||
pumpExecutionLoop()
|
||
return
|
||
}
|
||
|
||
await pullNextTask('monitor')
|
||
}
|
||
|
||
async function handleMonitoringTaskControl(event: DesktopTaskEventMessage): Promise<void> {
|
||
if (
|
||
event.target_client_id !== currentClientID() ||
|
||
event.control !== 'interrupt_requested' ||
|
||
!event.task_id.trim()
|
||
) {
|
||
return
|
||
}
|
||
|
||
const taskId = event.task_id.trim()
|
||
const generation = toInterruptGeneration(event.interrupt_generation)
|
||
const existing = state.tasks.get(taskId) ?? null
|
||
if (existing && generation < toInterruptGeneration(existing.payload.interrupt_generation)) {
|
||
return
|
||
}
|
||
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (active?.kind === 'monitor') {
|
||
if (isDesktopTaskID(taskId)) {
|
||
if (existing) {
|
||
existing.summary =
|
||
'已收到立即收集请求;当前执行继续保留,high lane 任务会按平台隔离和并发上限调度。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
recordActivity(
|
||
'info',
|
||
'监控任务保留当前执行',
|
||
`${existing?.title ?? taskId} 当前执行继续保留;若 high lane 属于其他平台可使用第二个窗口并行,否则等待当前平台释放。`,
|
||
)
|
||
return
|
||
}
|
||
|
||
if (existing) {
|
||
existing.summary = '已收到抢占请求;当前监控执行将自然完成,随后优先处理 high lane 任务。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
recordActivity(
|
||
'warn',
|
||
'监控任务收到抢占请求',
|
||
`${existing?.title ?? taskId} 当前已在执行,Phase 1 将等待自然完成。`,
|
||
)
|
||
return
|
||
}
|
||
|
||
if (existing) {
|
||
existing.payload = {
|
||
...existing.payload,
|
||
interrupt_requested: true,
|
||
interrupt_generation: generation,
|
||
interrupt_reason: event.reason?.trim() || 'collect_now_preempt',
|
||
replacement_task_id: event.replacement_task_id?.trim() || null,
|
||
}
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
|
||
if (!existing?.leaseToken) {
|
||
noteMonitorTaskCanceled(taskId)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.summary = '已收到抢占请求,本地排队中的旧监控任务已让路。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
await pullMonitoringTasks('rabbitmq-primary')
|
||
return
|
||
}
|
||
|
||
const monitoringTaskID = Number.parseInt(taskId, 10)
|
||
if (!Number.isFinite(monitoringTaskID)) {
|
||
noteMonitorTaskCanceled(taskId)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.leaseToken = null
|
||
existing.summary =
|
||
'已收到抢占请求,但任务不是 legacy monitoring lease,已从本地调度队列移除。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
await pullMonitoringTasks('rabbitmq-primary')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await cancelMonitoringTask(monitoringTaskID, {
|
||
lease_token: existing.leaseToken,
|
||
reason: event.reason?.trim() || 'collect_now_preempt',
|
||
})
|
||
noteMonitorTaskCanceled(taskId)
|
||
existing.status = 'aborted'
|
||
existing.leaseToken = null
|
||
existing.summary = '已响应抢占请求,释放旧监控租约并为高优任务让路。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
recordActivity('info', '已取消旧监控租约', `${existing.title} 已从本地排队队列中移除。`)
|
||
await pullMonitoringTasks('rabbitmq-primary')
|
||
} catch (error) {
|
||
const message = errorMessage(error)
|
||
if (existing) {
|
||
existing.summary = `收到抢占请求,但取消租约失败:${message}`
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
recordActivity(
|
||
'warn',
|
||
'监控租约抢占失败',
|
||
`${existing?.title ?? taskId} 取消租约失败:${message}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
async function sendHeartbeat(source: 'startup' | 'timer'): Promise<void> {
|
||
if (!canRunLive(state.session)) {
|
||
return
|
||
}
|
||
|
||
const previousStatus = state.lastHeartbeatStatus
|
||
|
||
try {
|
||
const response = await heartbeatDesktopClient({
|
||
device_name: hostname() || state.client?.device_name || `Desktop ${process.platform}`,
|
||
os: process.platform,
|
||
cpu_arch: process.arch,
|
||
client_version: getDesktopAppVersion(),
|
||
channel: state.client?.channel ?? getDesktopReleaseChannel(),
|
||
startup: source === 'startup',
|
||
account_ids: state.accounts
|
||
.filter((account) => shouldReportAccountPresence(account))
|
||
.map((account) => account.id),
|
||
})
|
||
|
||
state.client = response.client
|
||
state.lastHeartbeatAt = Date.now()
|
||
state.lastHeartbeatStatus = 'success'
|
||
state.lastServerTime = response.server_time
|
||
state.onlineClientCount =
|
||
response.current_user_online_client_count ?? response.online_client_count ?? null
|
||
state.lastError = null
|
||
|
||
noteTransportHeartbeat(true)
|
||
noteSchedulerHeartbeat()
|
||
setSchedulerError(null)
|
||
|
||
if (source === 'startup' || previousStatus === 'failed') {
|
||
recordActivity('success', '心跳连接正常', '客户端心跳已回写到服务端。')
|
||
}
|
||
} catch (error) {
|
||
state.lastHeartbeatAt = Date.now()
|
||
state.lastHeartbeatStatus = 'failed'
|
||
state.lastError = errorMessage(error)
|
||
|
||
noteTransportHeartbeat(false)
|
||
setSchedulerError(state.lastError)
|
||
|
||
if (source === 'startup' || previousStatus !== 'failed') {
|
||
recordActivity('danger', '心跳连接失败', state.lastError)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
||
if (!canRunLive(state.session)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const accounts = await listDesktopAccounts()
|
||
state.lastAccountsSyncAt = Date.now()
|
||
state.lastError = null
|
||
|
||
noteSchedulerAccountsSync()
|
||
setSchedulerError(null)
|
||
|
||
const identities = accounts
|
||
.filter((account) => hasLocalAccountAuthorization(account))
|
||
.map((account) => accountIdentityFromDesktopAccount(account))
|
||
syncTrackedAccounts(identities)
|
||
state.accountRemoteHealth = new Map(accounts.map((account) => [account.id, account.health]))
|
||
|
||
state.accountProfiles.clear()
|
||
const syncedAccounts: Array<DesktopAccountInfo | null> = accounts.map((account) => {
|
||
const normalizedPlatformUid =
|
||
normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid
|
||
const isAIPlatform = isAIPlatformId(account.platform)
|
||
const accountIdentity = accountIdentityFromDesktopAccount({
|
||
...account,
|
||
platform_uid: normalizedPlatformUid,
|
||
})
|
||
if (accountRequiresLocalAuthorization(account) && !hasLocalAccountAuthorization(account)) {
|
||
markTrackedAccountMissingPartition(accountIdentity)
|
||
}
|
||
const projected = getProjectedAccountHealth({
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
health: account.health,
|
||
verifiedAt: account.verified_at,
|
||
})
|
||
|
||
if (projected.profile?.subjectUid && projected.profile.displayName) {
|
||
state.accountProfiles.set(account.id, {
|
||
platformUid: projected.profile.subjectUid,
|
||
displayName: projected.profile.displayName,
|
||
avatarUrl: projected.profile.avatarUrl,
|
||
})
|
||
}
|
||
|
||
const normalizedLocalPlatformUid = projected.profile?.subjectUid
|
||
? normalizeAccountPlatformUid(projected.profile.subjectUid) || projected.profile.subjectUid
|
||
: null
|
||
const hasPlatformUidMismatch = Boolean(
|
||
normalizedLocalPlatformUid &&
|
||
!sameAccountPlatformUid(normalizedLocalPlatformUid, normalizedPlatformUid),
|
||
)
|
||
|
||
if (hasPlatformUidMismatch && !isAIPlatform) {
|
||
return {
|
||
...account,
|
||
platform_uid: normalizedPlatformUid,
|
||
health: 'expired' as const,
|
||
}
|
||
}
|
||
|
||
const resolvedAccount: DesktopAccountInfo = {
|
||
...account,
|
||
platform_uid: normalizedPlatformUid,
|
||
display_name: projected.profile?.displayName ?? account.display_name,
|
||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||
health: projected.health,
|
||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||
}
|
||
|
||
return resolvedAccount
|
||
})
|
||
const duplicateAccounts: DesktopAccountInfo[] = []
|
||
const dedupedAccounts: DesktopAccountInfo[] = []
|
||
const seenAccountIndexes = new Map<string, number>()
|
||
|
||
for (const account of syncedAccounts) {
|
||
if (!account) {
|
||
continue
|
||
}
|
||
|
||
const identityKey = buildRuntimeAccountDedupeKey(account)
|
||
const existingIndex = seenAccountIndexes.get(identityKey)
|
||
if (existingIndex !== undefined) {
|
||
const existingAccount = dedupedAccounts[existingIndex]
|
||
if (shouldPreferRuntimeAccount(account, existingAccount)) {
|
||
duplicateAccounts.push(existingAccount)
|
||
dedupedAccounts[existingIndex] = account
|
||
} else {
|
||
duplicateAccounts.push(account)
|
||
}
|
||
continue
|
||
}
|
||
|
||
seenAccountIndexes.set(identityKey, dedupedAccounts.length)
|
||
dedupedAccounts.push(account)
|
||
}
|
||
|
||
state.accounts = dedupedAccounts
|
||
|
||
if (duplicateAccounts.length > 0) {
|
||
void cleanupDuplicateDesktopAccounts(duplicateAccounts)
|
||
}
|
||
|
||
if (source === 'startup') {
|
||
recordActivity('info', '本地账号同步', `已同步 ${state.accounts.length} 个桌面账号。`)
|
||
}
|
||
|
||
refreshAccountNames()
|
||
pumpExecutionLoop()
|
||
void sendHeartbeat('timer')
|
||
} catch (error) {
|
||
const message = errorMessage(error)
|
||
state.lastError = message
|
||
setSchedulerError(message)
|
||
|
||
recordActivity('warn', '账号同步失败', message)
|
||
}
|
||
}
|
||
|
||
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
|
||
for (const account of accounts) {
|
||
try {
|
||
await deleteDesktopAccount(account.id, account.sync_version)
|
||
await clearSessionHandle(account.id)
|
||
forgetTrackedAccountHealth(account.id)
|
||
} catch (error) {
|
||
console.warn('[desktop-runtime] duplicate desktop account cleanup failed', {
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
platformUid: account.platform_uid,
|
||
message: errorMessage(error),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildAccountHealthReport(account: DesktopAccountInfo): DesktopAccountHealthReport {
|
||
const projected = getProjectedAccountHealth({
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
health: account.health,
|
||
verifiedAt: account.verified_at,
|
||
})
|
||
const health =
|
||
projected.authState === 'expiring_soon' && projected.probeState !== 'network_error'
|
||
? 'live'
|
||
: projected.health
|
||
return {
|
||
account_id: account.id,
|
||
platform: account.platform,
|
||
platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||
health,
|
||
auth_state: projected.authState,
|
||
probe_state: projected.probeState,
|
||
auth_reason: projected.authReason,
|
||
display_name: projected.profile?.displayName ?? account.display_name,
|
||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||
checked_at: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
function isAccountHealthFailureReport(report: DesktopAccountHealthReport): boolean {
|
||
return (
|
||
report.health !== 'live' ||
|
||
report.auth_state === 'expired' ||
|
||
report.auth_state === 'challenge_required' ||
|
||
report.auth_state === 'revoked' ||
|
||
report.probe_state === 'network_error'
|
||
)
|
||
}
|
||
|
||
function shouldQueueAccountHealthReport(
|
||
account: DesktopAccountInfo,
|
||
report: DesktopAccountHealthReport,
|
||
): boolean {
|
||
if (isAccountHealthFailureReport(report)) {
|
||
return true
|
||
}
|
||
|
||
if (state.accountHealthReportBadAccounts.has(account.id)) {
|
||
return true
|
||
}
|
||
|
||
return (state.accountRemoteHealth.get(account.id) ?? account.health) !== 'live'
|
||
}
|
||
|
||
function enqueueAccountHealthReport(accountId: string): void {
|
||
const account = state.accounts.find((item) => item.id === accountId) ?? null
|
||
if (!account) {
|
||
return
|
||
}
|
||
if (!hasLocalAccountAuthorization(account)) {
|
||
return
|
||
}
|
||
|
||
const report = buildAccountHealthReport(account)
|
||
if (!shouldQueueAccountHealthReport(account, report)) {
|
||
return
|
||
}
|
||
|
||
if (isAccountHealthFailureReport(report)) {
|
||
state.accountHealthReportBadAccounts.add(account.id)
|
||
}
|
||
|
||
state.accountHealthReportQueue.set(account.id, report)
|
||
if (state.accountHealthReportTimer) {
|
||
return
|
||
}
|
||
|
||
state.accountHealthReportTimer = setTimeout(() => {
|
||
state.accountHealthReportTimer = null
|
||
void flushAccountHealthReports()
|
||
}, accountHealthReportDebounceMs)
|
||
}
|
||
|
||
async function flushAccountHealthReports(): Promise<void> {
|
||
if (state.accountHealthReportQueue.size === 0 || !canRunLive(state.session)) {
|
||
return
|
||
}
|
||
|
||
const reports = [...state.accountHealthReportQueue.values()].slice(
|
||
0,
|
||
accountHealthReportMaxBatchSize,
|
||
)
|
||
for (const report of reports) {
|
||
state.accountHealthReportQueue.delete(report.account_id)
|
||
}
|
||
|
||
try {
|
||
await reportDesktopAccountHealth({ reports })
|
||
for (const report of reports) {
|
||
state.accountRemoteHealth.set(report.account_id, report.health)
|
||
if (isAccountHealthFailureReport(report)) {
|
||
state.accountHealthReportBadAccounts.add(report.account_id)
|
||
} else {
|
||
state.accountHealthReportBadAccounts.delete(report.account_id)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('[desktop-runtime] account health report failed', {
|
||
count: reports.length,
|
||
message: errorMessage(error),
|
||
})
|
||
for (const report of reports) {
|
||
state.accountHealthReportQueue.set(report.account_id, report)
|
||
}
|
||
} finally {
|
||
if (state.accountHealthReportQueue.size > 0 && !state.accountHealthReportTimer) {
|
||
state.accountHealthReportTimer = setTimeout(() => {
|
||
state.accountHealthReportTimer = null
|
||
void flushAccountHealthReports()
|
||
}, accountHealthReportDebounceMs)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function mirrorProjectedAccountProfile(accountId: string): Promise<void> {
|
||
const account = state.accounts.find((item) => item.id === accountId) ?? null
|
||
if (!account) {
|
||
return
|
||
}
|
||
|
||
const projected = getProjectedAccountHealth({
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
health: account.health,
|
||
verifiedAt: account.verified_at,
|
||
})
|
||
if (!isDefinitiveAuthState(projected.authState)) {
|
||
return
|
||
}
|
||
|
||
const nextDisplayName = projected.profile?.displayName ?? account.display_name
|
||
const nextAvatarUrl = projected.profile?.avatarUrl ?? account.avatar_url
|
||
const shouldMirrorProfile =
|
||
account.display_name !== nextDisplayName || account.avatar_url !== nextAvatarUrl
|
||
if (!shouldMirrorProfile) {
|
||
return
|
||
}
|
||
|
||
const normalizedPlatformUid =
|
||
normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid
|
||
const updated = await upsertDesktopAccount({
|
||
platform: account.platform,
|
||
platform_uid: normalizedPlatformUid,
|
||
display_name: nextDisplayName,
|
||
avatar_url: nextAvatarUrl,
|
||
account_fingerprint: account.account_fingerprint ?? normalizedPlatformUid,
|
||
health: account.health,
|
||
verified_at: account.verified_at,
|
||
tags: account.tags,
|
||
})
|
||
|
||
state.accounts = state.accounts.map((item) =>
|
||
item.id === account.id
|
||
? {
|
||
...updated,
|
||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||
health: account.health,
|
||
}
|
||
: item,
|
||
)
|
||
state.accountProfiles.set(account.id, {
|
||
platformUid: normalizedPlatformUid,
|
||
displayName: nextDisplayName,
|
||
avatarUrl: nextAvatarUrl,
|
||
})
|
||
refreshAccountNames()
|
||
}
|
||
|
||
export async function refreshRuntimeAccounts(): Promise<void> {
|
||
await syncAccounts('manual')
|
||
}
|
||
|
||
export async function requestRuntimeAccountProbe(
|
||
accountId: string,
|
||
options: { account?: PublishAccountIdentity; force?: boolean } = {},
|
||
): Promise<void> {
|
||
const existingAccount = state.accounts.find((item) => item.id === accountId) ?? null
|
||
const account =
|
||
options.account ?? (existingAccount ? accountIdentityFromDesktopAccount(existingAccount) : null)
|
||
if (!account) {
|
||
throw new Error('runtime_account_not_found')
|
||
}
|
||
if (existingAccount && !hasLocalAccountAuthorization(existingAccount)) {
|
||
markTrackedAccountMissingPartition(account)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await probeTrackedAccount(account, {
|
||
trigger: 'manual',
|
||
allowSilentRefresh: true,
|
||
force: options.force ?? true,
|
||
})
|
||
enqueueAccountHealthReport(account.id)
|
||
await mirrorProjectedAccountProfile(account.id)
|
||
} catch (error) {
|
||
console.warn('[desktop-runtime] account probe failed', {
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
message: errorMessage(error),
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
||
const verifiedAt = new Date().toISOString()
|
||
const normalizedAccount: DesktopAccountInfo = {
|
||
...account,
|
||
platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||
health: 'live',
|
||
verified_at: account.verified_at ?? verifiedAt,
|
||
}
|
||
const existingIndex = state.accounts.findIndex(
|
||
(item) =>
|
||
item.id === normalizedAccount.id ||
|
||
(isAIPlatformId(normalizedAccount.platform) &&
|
||
item.platform === normalizedAccount.platform) ||
|
||
(item.platform === normalizedAccount.platform &&
|
||
sameAccountPlatformUid(item.platform_uid, normalizedAccount.platform_uid)),
|
||
)
|
||
const replacedAccount =
|
||
existingIndex >= 0 && state.accounts[existingIndex].id !== normalizedAccount.id
|
||
? state.accounts[existingIndex]
|
||
: null
|
||
|
||
if (existingIndex >= 0) {
|
||
state.accounts = state.accounts.map((item, index) =>
|
||
index === existingIndex ? normalizedAccount : item,
|
||
)
|
||
} else {
|
||
state.accounts = [normalizedAccount, ...state.accounts]
|
||
}
|
||
|
||
state.accountProfiles.set(normalizedAccount.id, {
|
||
platformUid: normalizedAccount.platform_uid,
|
||
displayName: normalizedAccount.display_name,
|
||
avatarUrl: normalizedAccount.avatar_url,
|
||
})
|
||
if (replacedAccount) {
|
||
state.accountProfiles.delete(replacedAccount.id)
|
||
forgetTrackedAccountHealth(replacedAccount.id)
|
||
void clearSessionHandle(replacedAccount.id)
|
||
void deleteDesktopAccount(replacedAccount.id, replacedAccount.sync_version).catch((error) => {
|
||
console.warn('[desktop-runtime] replaced desktop account cleanup failed', {
|
||
accountId: replacedAccount.id,
|
||
platform: replacedAccount.platform,
|
||
platformUid: replacedAccount.platform_uid,
|
||
message: errorMessage(error),
|
||
})
|
||
})
|
||
}
|
||
state.lastAccountsSyncAt = Date.now()
|
||
syncTrackedAccounts(
|
||
state.accounts
|
||
.filter((item) => hasLocalAccountAuthorization(item))
|
||
.map((item) => accountIdentityFromDesktopAccount(item)),
|
||
)
|
||
markTrackedAccountBound(accountIdentityFromDesktopAccount(normalizedAccount), {
|
||
subjectUid: normalizedAccount.platform_uid,
|
||
displayName: normalizedAccount.display_name,
|
||
avatarUrl: normalizedAccount.avatar_url,
|
||
})
|
||
enqueueAccountHealthReport(normalizedAccount.id)
|
||
refreshAccountNames()
|
||
}
|
||
|
||
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)
|
||
forgetTrackedAccountHealth(accountId)
|
||
|
||
state.accounts = state.accounts.filter((item) => item.id !== accountId)
|
||
state.accountProfiles.delete(accountId)
|
||
refreshAccountNames()
|
||
|
||
if (matchedAccount) {
|
||
recordActivity(
|
||
'info',
|
||
'账号已解绑',
|
||
`${matchedAccount.display_name} 已解绑,并清理了当前机器上的本地会话缓存。`,
|
||
)
|
||
}
|
||
|
||
await syncAccounts('manual')
|
||
}
|
||
|
||
function enqueuePublishTask(taskId: string, platform: string, routing: RuntimeTaskRouting): void {
|
||
if (!taskId || state.activeExecutions.has(taskId)) {
|
||
return
|
||
}
|
||
|
||
enqueuePublishSchedulerTask({ taskId, platform, routing })
|
||
syncSchedulerSurface()
|
||
}
|
||
|
||
function createActiveExecution(
|
||
taskRecord: RuntimeTaskRecord,
|
||
abortController: AbortController,
|
||
): ActiveRuntimeExecution {
|
||
const now = Date.now()
|
||
return {
|
||
taskId: taskRecord.id,
|
||
kind: taskRecord.kind,
|
||
platform: taskRecord.platform,
|
||
accountId: taskRecord.accountId,
|
||
abortController,
|
||
executionPhase: taskRecord.kind === 'monitor' ? 'PREPARING' : null,
|
||
interruptRequested:
|
||
taskRecord.kind === 'monitor' && Boolean(taskRecord.payload.interrupt_requested),
|
||
interruptGeneration:
|
||
taskRecord.kind === 'monitor'
|
||
? toInterruptGeneration(taskRecord.payload.interrupt_generation)
|
||
: 0,
|
||
interruptReason: taskRecord.kind === 'monitor' ? interruptReasonFromTask(taskRecord) : null,
|
||
lastSafePointAt: taskRecord.kind === 'monitor' ? now : null,
|
||
startedAt: now,
|
||
lastProgressAt: now,
|
||
lastProgressSummary: taskRecord.summary,
|
||
deadlineAt: null,
|
||
deadlineHandle: null,
|
||
longRunningNotifiedAt: null,
|
||
submitStartedAt: null,
|
||
}
|
||
}
|
||
|
||
function armPublishTaskDeadline(
|
||
taskRecord: RuntimeTaskRecord,
|
||
active: ActiveRuntimeExecution,
|
||
): void {
|
||
if (taskRecord.kind !== 'publish') {
|
||
return
|
||
}
|
||
|
||
const taskDeadlineMs = resolvePublishTaskTimeoutMs(taskRecord.platform)
|
||
active.deadlineAt = active.startedAt + taskDeadlineMs
|
||
active.deadlineHandle = setTimeout(() => {
|
||
if (active.abortController.signal.aborted) {
|
||
return
|
||
}
|
||
|
||
const timeoutError = new Error('publish_task_timeout')
|
||
recordActivity(
|
||
'warn',
|
||
'任务超时',
|
||
`${taskRecord.title} 执行超过 ${Math.round(taskDeadlineMs / 1000)}s,已中止并释放。`,
|
||
)
|
||
updateTaskProgress(taskRecord.id, `${taskRecord.title} 执行超时,正在释放任务。`)
|
||
active.abortController.abort(timeoutError)
|
||
}, taskDeadlineMs)
|
||
}
|
||
|
||
function resolvePublishTaskTimeoutMs(platform: string): number {
|
||
const envKey = `GEO_DESKTOP_PUBLISH_TIMEOUT_${platform.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MS`
|
||
return (
|
||
resolvePositiveEnvInteger(envKey) ??
|
||
resolvePositiveEnvInteger('GEO_DESKTOP_PUBLISH_TIMEOUT_MS') ??
|
||
defaultPublishTaskTimeoutMs
|
||
)
|
||
}
|
||
|
||
function resolvePositiveEnvInteger(key: string): number | null {
|
||
const raw = process.env[key]?.trim()
|
||
if (!raw) {
|
||
return null
|
||
}
|
||
const parsed = Number.parseInt(raw, 10)
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||
}
|
||
|
||
// Stages emitted by an adapter right before it performs an irreversible platform write (creating
|
||
// the article/draft). After this point the article may exist on the platform regardless of whether
|
||
// the local task records it, so an interrupted publish must not be auto-retried.
|
||
function isIrreversiblePublishStage(stage: string): boolean {
|
||
const normalized = stage.trim().toLowerCase()
|
||
return (
|
||
normalized.endsWith('.create_note') ||
|
||
normalized.endsWith('.create_article_id') ||
|
||
normalized.endsWith('.create_draft') ||
|
||
normalized.endsWith('.publish') ||
|
||
normalized.endsWith('.publish_draft') ||
|
||
normalized.endsWith('.save_content') ||
|
||
normalized.endsWith('.submit') ||
|
||
normalized.endsWith('.submit_editor') ||
|
||
normalized.endsWith('.save_draft') ||
|
||
normalized === 'create_note' ||
|
||
normalized === 'create_article_id' ||
|
||
normalized === 'create_draft' ||
|
||
normalized === 'publish' ||
|
||
normalized === 'publish_draft' ||
|
||
normalized === 'save_content' ||
|
||
normalized === 'submit' ||
|
||
normalized === 'submit_editor' ||
|
||
normalized === 'save_draft'
|
||
)
|
||
}
|
||
|
||
// Persist a durable "submit started" marker before the irreversible platform write. The local flag
|
||
// is set only after the server confirms the marker, so recovery can trust publish_submit_started_at.
|
||
async function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): Promise<void> {
|
||
if (!isIrreversiblePublishStage(stage)) {
|
||
return
|
||
}
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (!active || active.kind !== 'publish' || active.submitStartedAt !== null) {
|
||
return
|
||
}
|
||
const leaseToken = state.tasks.get(taskId)?.leaseToken
|
||
if (!leaseToken) {
|
||
throw new Error('publish_submit_marker_lease_missing')
|
||
}
|
||
await markDesktopTaskPublishSubmitStarted(taskId, leaseToken)
|
||
|
||
active.submitStartedAt = Date.now()
|
||
state.activeExecutions.set(taskId, active)
|
||
}
|
||
|
||
function asSubmitUncertainExecution(result: AdapterExecutionResult): AdapterExecutionResult {
|
||
if (result.status !== 'failed') {
|
||
return result
|
||
}
|
||
return {
|
||
...result,
|
||
status: 'unknown',
|
||
summary: '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。',
|
||
error: {
|
||
...(result.error ?? {}),
|
||
publish_submit_uncertain: true,
|
||
original_status: 'failed',
|
||
original_summary: result.summary,
|
||
},
|
||
}
|
||
}
|
||
|
||
function pumpExecutionLoop(): void {
|
||
if (!state.running) {
|
||
return
|
||
}
|
||
|
||
syncSchedulerSurface()
|
||
|
||
if (canStartAnotherPublishExecution()) {
|
||
const selected = selectNextPublishTask({
|
||
maxConcurrency: resolveAdaptiveTotalConcurrency(),
|
||
activePlatforms: activePublishPlatforms(),
|
||
activeCount: activePublishCount(),
|
||
})
|
||
if (selected) {
|
||
void leaseSpecificTask(selected, 'publish')
|
||
return
|
||
}
|
||
}
|
||
|
||
if (shouldPullPublishFallback()) {
|
||
void pullNextTask('publish')
|
||
return
|
||
}
|
||
|
||
if (hasPublishBacklog()) {
|
||
return
|
||
}
|
||
|
||
if (canStartAnotherMonitorExecution()) {
|
||
const selected = selectNextMonitorTask({
|
||
maxConcurrency: resolveAdaptiveMonitorConcurrency(),
|
||
activePlatforms: activeMonitorPlatforms(),
|
||
activeQuestionKeys: activeMonitorQuestionKeys(),
|
||
activeCount: activeMonitorCount(),
|
||
})
|
||
if (selected) {
|
||
void leaseSpecificTask(selected, 'monitor')
|
||
return
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
async function leaseSpecificTask(
|
||
request: PendingTaskRequest,
|
||
expectedKind: 'publish' | 'monitor',
|
||
): Promise<void> {
|
||
if (!canRunLive(state.session) || isLeaseInFlight(expectedKind)) {
|
||
return
|
||
}
|
||
|
||
state.leaseInFlightKinds.add(expectedKind)
|
||
|
||
try {
|
||
if (expectedKind === 'monitor' && request.source === 'monitoring_lease') {
|
||
state.leaseInFlightKinds.delete(expectedKind)
|
||
await executeLeasedMonitoringTask(request.taskId, request.routing)
|
||
return
|
||
}
|
||
|
||
const leased = await leaseDesktopTask({ task_id: request.taskId })
|
||
if (!leased.task) {
|
||
if (expectedKind === 'monitor') {
|
||
noteMonitorTaskLeaseMiss(request.taskId)
|
||
} else {
|
||
notePublishTaskLeaseMiss(request.taskId)
|
||
}
|
||
return
|
||
}
|
||
state.leaseInFlightKinds.delete(expectedKind)
|
||
await executeLeasedTask(leased, request.routing)
|
||
} catch (error) {
|
||
if (expectedKind === 'monitor') {
|
||
if (error instanceof ApiClientError && error.code === 40991) {
|
||
noteMonitorTaskCanceled(request.taskId)
|
||
const existing = state.tasks.get(request.taskId)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.leaseToken = null
|
||
existing.summary = '本地缓存的监控任务已不在服务端 queued,已从调度队列移除。'
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(request.taskId, existing)
|
||
}
|
||
} else {
|
||
noteMonitorTaskLeaseMiss(request.taskId)
|
||
}
|
||
} else {
|
||
notePublishTaskLeaseMiss(request.taskId)
|
||
}
|
||
|
||
state.lastError = errorMessage(error)
|
||
setSchedulerError(state.lastError)
|
||
recordActivity('warn', '指定任务领取失败', `${request.taskId} 未能成功领取:${state.lastError}`)
|
||
} finally {
|
||
if (state.leaseInFlightKinds.has(expectedKind)) {
|
||
state.leaseInFlightKinds.delete(expectedKind)
|
||
syncSchedulerSurface()
|
||
if (localQueueDepth() > 0) {
|
||
pumpExecutionLoop()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pullNextTask(kind: 'publish' | 'monitor'): Promise<void> {
|
||
if (kind === 'monitor') {
|
||
if (!canRunLive(state.session) || isLeaseInFlight('monitor')) {
|
||
return
|
||
}
|
||
|
||
state.leaseInFlightKinds.add('monitor')
|
||
try {
|
||
const leased = await leaseDesktopTask({ kind: 'monitor' })
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'success'
|
||
noteTransportPull(true)
|
||
noteSchedulerPull()
|
||
|
||
if (leased.task) {
|
||
state.leaseInFlightKinds.delete('monitor')
|
||
await executeLeasedTask(leased, 'rabbitmq-primary')
|
||
return
|
||
}
|
||
} catch (error) {
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'failed'
|
||
state.lastError = errorMessage(error)
|
||
noteTransportPull(false)
|
||
setSchedulerError(state.lastError)
|
||
} finally {
|
||
state.leaseInFlightKinds.delete('monitor')
|
||
syncSchedulerSurface()
|
||
}
|
||
|
||
await pullMonitoringTasks('db-recovery')
|
||
return
|
||
}
|
||
|
||
if (!canRunLive(state.session) || isLeaseInFlight('publish')) {
|
||
return
|
||
}
|
||
|
||
let leasedPublishTask = false
|
||
state.leaseInFlightKinds.add('publish')
|
||
|
||
try {
|
||
const leased = await leaseDesktopTask({ kind })
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'success'
|
||
noteTransportPull(true)
|
||
noteSchedulerPull()
|
||
|
||
if (!leased.task) {
|
||
state.publishFallbackBackoffUntil = Date.now() + pullIntervalMs
|
||
return
|
||
}
|
||
|
||
state.publishFallbackBackoffUntil = 0
|
||
leasedPublishTask = true
|
||
state.leaseInFlightKinds.delete('publish')
|
||
await executeLeasedTask(leased, 'db-recovery')
|
||
} catch (error) {
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'failed'
|
||
state.lastError = errorMessage(error)
|
||
state.publishFallbackBackoffUntil = Date.now() + pullIntervalMs
|
||
|
||
noteTransportPull(false)
|
||
setSchedulerError(state.lastError)
|
||
|
||
recordActivity('warn', '兜底拉取失败', state.lastError)
|
||
} finally {
|
||
state.leaseInFlightKinds.delete('publish')
|
||
syncSchedulerSurface()
|
||
if (!leasedPublishTask && state.running) {
|
||
queueMicrotask(() => pumpExecutionLoop())
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise<void> {
|
||
const leaseLimit = resolveLegacyMonitoringLeaseLimit()
|
||
if (!canRunLive(state.session) || isLeaseInFlight('monitor') || leaseLimit <= 0) {
|
||
return
|
||
}
|
||
|
||
state.leaseInFlightKinds.add('monitor')
|
||
|
||
try {
|
||
const leased = await leaseMonitoringTasks({
|
||
limit: leaseLimit,
|
||
})
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'success'
|
||
noteTransportPull(true)
|
||
noteSchedulerPull()
|
||
|
||
enqueueLeasedMonitoringTasks(leased.tasks, routing)
|
||
} catch (error) {
|
||
state.lastPullAt = Date.now()
|
||
state.lastPullStatus = 'failed'
|
||
state.lastError = errorMessage(error)
|
||
noteTransportPull(false)
|
||
setSchedulerError(state.lastError)
|
||
|
||
recordActivity('warn', '监控任务拉取失败', state.lastError)
|
||
} finally {
|
||
state.leaseInFlightKinds.delete('monitor')
|
||
syncSchedulerSurface()
|
||
}
|
||
}
|
||
|
||
async function resumeLeasedMonitoringTasks(source: 'startup' | 'reconnect'): Promise<void> {
|
||
if (!canRunLive(state.session)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const resumed = await resumeMonitoringTasks({})
|
||
enqueueLeasedMonitoringTasks(resumed.tasks, 'db-recovery')
|
||
if (resumed.tasks.length > 0) {
|
||
recordActivity(
|
||
'info',
|
||
'已恢复监控租约',
|
||
`从服务端恢复了 ${resumed.tasks.length} 条未完成监控任务(${source})。`,
|
||
)
|
||
}
|
||
void pullNextTask('monitor')
|
||
} catch (error) {
|
||
recordActivity('warn', '恢复监控租约失败', errorMessage(error))
|
||
}
|
||
}
|
||
|
||
function enqueueLeasedMonitoringTasks(
|
||
tasks: MonitoringLeaseTask[],
|
||
routing: RuntimeTaskRouting,
|
||
): void {
|
||
if (tasks.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const task of tasks) {
|
||
const taskId = String(task.task_id)
|
||
const accountId = resolveMonitoringAccountID(task.ai_platform_id)
|
||
const now = Date.now()
|
||
state.tasks.set(taskId, {
|
||
id: taskId,
|
||
jobId: taskId,
|
||
title: buildMonitoringTaskTitle(task),
|
||
kind: 'monitor',
|
||
platform: task.ai_platform_id,
|
||
accountId,
|
||
accountName: accountId
|
||
? resolveAccountName(accountId, task.platform_name)
|
||
: task.platform_name,
|
||
clientId: state.client?.id ?? taskId,
|
||
status: 'queued',
|
||
routing,
|
||
leaseExpiresAt: parseTimestamp(task.lease_expires_at),
|
||
startedAt: null,
|
||
updatedAt: now,
|
||
summary: `已领取监控租约,等待本地调度执行(${describeRouting(routing)})。`,
|
||
payload: {
|
||
title: buildMonitoringTaskTitle(task),
|
||
question_id: task.question_id,
|
||
question_hash: task.question_hash,
|
||
question_text: task.question_text,
|
||
ai_platform_id: task.ai_platform_id,
|
||
platform_name: task.platform_name,
|
||
collector_type: task.collector_type,
|
||
run_mode: task.run_mode,
|
||
trigger_source: task.trigger_source,
|
||
business_date: task.business_date,
|
||
priority: typeof task.priority === 'number' ? task.priority : null,
|
||
lane: typeof task.lane === 'string' ? task.lane : null,
|
||
monitoring_task_id: task.task_id,
|
||
lease_token: task.lease_token,
|
||
lease_expires_at: task.lease_expires_at,
|
||
scheduler_group_key: task.question_hash || `qid:${task.question_id}`,
|
||
legacy_monitoring_task: true,
|
||
},
|
||
error: null,
|
||
result: null,
|
||
attemptId: null,
|
||
leaseToken: task.lease_token,
|
||
})
|
||
enqueueMonitorLeaseTask({
|
||
taskId,
|
||
jobId: taskId,
|
||
accountId,
|
||
clientId: state.client?.id ?? taskId,
|
||
platform: task.ai_platform_id,
|
||
routing,
|
||
priority:
|
||
typeof task.priority === 'number'
|
||
? task.priority
|
||
: task.trigger_source === 'manual'
|
||
? 5000
|
||
: 100,
|
||
lane:
|
||
typeof task.lane === 'string'
|
||
? task.lane
|
||
: task.trigger_source === 'manual'
|
||
? 'high'
|
||
: 'normal',
|
||
title: buildMonitoringTaskTitle(task),
|
||
businessDate: task.business_date,
|
||
questionKey: task.question_hash || `qid:${task.question_id}`,
|
||
questionText: task.question_text,
|
||
})
|
||
}
|
||
|
||
syncSchedulerSurface()
|
||
pumpExecutionLoop()
|
||
}
|
||
|
||
async function executeLeasedMonitoringTask(
|
||
taskId: string,
|
||
_routing: RuntimeTaskRouting,
|
||
): Promise<void> {
|
||
const taskRecord = state.tasks.get(taskId)
|
||
if (!taskRecord || taskRecord.kind !== 'monitor' || !taskRecord.leaseToken) {
|
||
noteMonitorTaskLeaseMiss(taskId)
|
||
return
|
||
}
|
||
|
||
const monitoringTaskID = Number.parseInt(taskId, 10)
|
||
if (!Number.isFinite(monitoringTaskID)) {
|
||
noteMonitorTaskCanceled(taskId)
|
||
state.tasks.delete(taskId)
|
||
return
|
||
}
|
||
|
||
setSchedulerPhase('running')
|
||
setActiveLease({
|
||
taskId,
|
||
attemptId: null,
|
||
leaseExpiresAt: isoFromTimestamp(taskRecord.leaseExpiresAt),
|
||
})
|
||
|
||
recordActivity('info', '监控任务开始执行', `${taskRecord.title} 已进入本地执行队列。`)
|
||
|
||
const abortController = new AbortController()
|
||
state.activeExecutions.set(taskRecord.id, createActiveExecution(taskRecord, abortController))
|
||
noteMonitorTaskActivated(taskId)
|
||
syncSchedulerSurface()
|
||
queueMicrotask(() => pumpExecutionLoop())
|
||
|
||
try {
|
||
const execution =
|
||
(await resolveAuthorizationPreflightBlock(taskRecord)) ??
|
||
withAuthorizationRetryPolicy(
|
||
taskRecord,
|
||
await executeTaskAdapter(taskRecord, abortController.signal),
|
||
)
|
||
if (execution.status === 'unknown') {
|
||
const skipReason = monitoringSkipReasonFromExecution(execution)
|
||
await skipMonitoringTask(monitoringTaskID, {
|
||
lease_token: taskRecord.leaseToken,
|
||
skip_reason: skipReason,
|
||
error_message: execution.summary,
|
||
completed_at: new Date().toISOString(),
|
||
})
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.summary = execution.summary
|
||
existing.error = execution.error ?? null
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
noteLeaseReleased('completed', taskId)
|
||
noteMonitorTaskCompleted(taskId)
|
||
recordActivity(
|
||
skipReason === 'stale_business_date' ? 'info' : 'warn',
|
||
skipReason === 'stale_business_date' ? '监控任务已按跨日策略丢弃' : '监控任务结果待确认',
|
||
`${taskRecord.title} ${execution.summary}`,
|
||
)
|
||
return
|
||
}
|
||
|
||
await submitMonitoringTaskResult(
|
||
monitoringTaskID,
|
||
buildMonitoringResultPayload(taskRecord.leaseToken, execution),
|
||
)
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.status = execution.status
|
||
existing.summary = execution.summary
|
||
existing.result = execution.payload ?? null
|
||
existing.error = execution.error ?? null
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
noteLeaseReleased(execution.status === 'failed' ? 'failed' : 'completed', taskId)
|
||
noteMonitorTaskCompleted(taskId)
|
||
recordActivity(
|
||
execution.status === 'failed' ? 'danger' : 'success',
|
||
execution.status === 'failed' ? '监控任务执行失败' : '监控任务执行成功',
|
||
`${taskRecord.title} ${execution.summary}`,
|
||
)
|
||
} catch (error) {
|
||
if (isMonitorAbortReason(taskRecord, error, 'stale_business_date')) {
|
||
let dropSummary = staleMonitoringDropSummary(taskRecord)
|
||
try {
|
||
await skipMonitoringTask(monitoringTaskID, {
|
||
lease_token: taskRecord.leaseToken,
|
||
skip_reason: 'stale_business_date',
|
||
error_message: dropSummary,
|
||
completed_at: new Date().toISOString(),
|
||
})
|
||
} catch (skipError) {
|
||
dropSummary = `${dropSummary}(skip 回写失败:${errorMessage(skipError)})`
|
||
}
|
||
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.summary = dropSummary
|
||
existing.error = {
|
||
...toStructuredError(error),
|
||
dropped_reason: 'stale_business_date',
|
||
}
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
noteLeaseReleased('completed', taskId)
|
||
noteMonitorTaskCompleted(taskId)
|
||
recordActivity('info', '监控任务已按跨日策略丢弃', dropSummary)
|
||
return
|
||
}
|
||
|
||
const message = errorMessage(error)
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.status = 'failed'
|
||
existing.summary = message
|
||
existing.error = toStructuredError(error)
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
noteLeaseReleased('failed', taskId)
|
||
noteMonitorTaskCompleted(taskId)
|
||
recordActivity('danger', '监控任务执行失败', `${taskRecord.title} 执行失败:${message}`)
|
||
} finally {
|
||
state.activeExecutions.delete(taskId)
|
||
syncSchedulerSurface()
|
||
noteSchedulerTaskFinished()
|
||
pumpExecutionLoop()
|
||
}
|
||
}
|
||
|
||
function buildMonitoringTaskTitle(task: MonitoringLeaseTask): string {
|
||
const base = task.question_text.trim()
|
||
if (!base) {
|
||
return `监控任务 · ${task.platform_name}`
|
||
}
|
||
return `${task.platform_name} · ${base}`
|
||
}
|
||
|
||
function resolveMonitoringAccountID(platform: string): string {
|
||
const live = state.accounts.find(
|
||
(account) =>
|
||
account.platform === platform &&
|
||
getProjectedAccountHealth({
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
health: account.health,
|
||
verifiedAt: account.verified_at,
|
||
}).health === 'live',
|
||
)
|
||
if (live) {
|
||
return live.id
|
||
}
|
||
return state.accounts.find((account) => account.platform === platform)?.id ?? ''
|
||
}
|
||
|
||
function buildMonitoringResultPayload(
|
||
leaseToken: string,
|
||
execution: AdapterExecutionResult,
|
||
): MonitoringTaskResultPayload {
|
||
const payload = execution.payload ?? {}
|
||
const status = execution.status === 'succeeded' ? 'succeeded' : 'failed'
|
||
const rawResponseJSON = asRecord(payload.raw_response_json)
|
||
const failedRawResponseJSON =
|
||
status === 'failed' && execution.error
|
||
? {
|
||
...rawResponseJSON,
|
||
runtime_error: execution.error,
|
||
}
|
||
: rawResponseJSON
|
||
return {
|
||
lease_token: leaseToken,
|
||
status,
|
||
provider_model: asOptionalString(payload.provider_model),
|
||
provider_request_id: asOptionalString(payload.provider_request_id),
|
||
request_id: asOptionalString(payload.request_id),
|
||
answer: asOptionalString(payload.answer),
|
||
raw_response_json: failedRawResponseJSON,
|
||
citations: asSourceItems(payload.citations),
|
||
search_results: asSourceItems(payload.search_results),
|
||
brand_mentioned: asOptionalBoolean(payload.brand_mentioned),
|
||
brand_mention_position: asOptionalString(payload.brand_mention_position),
|
||
first_recommended: asOptionalBoolean(payload.first_recommended),
|
||
sentiment_label: asOptionalString(payload.sentiment_label),
|
||
matched_brand_terms: asStringArray(payload.matched_brand_terms),
|
||
error_message: status === 'failed' ? execution.summary : undefined,
|
||
completed_at: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
function buildMonitoringFailurePayload(
|
||
leaseToken: string,
|
||
failureMessage: string,
|
||
structuredError: Record<string, JsonValue>,
|
||
task?: RuntimeTaskRecord,
|
||
): MonitoringTaskResultPayload {
|
||
const runtimeError = task
|
||
? (withAuthorizationRetryPolicy(task, {
|
||
status: 'failed',
|
||
summary: failureMessage,
|
||
error: structuredError,
|
||
}).error ?? structuredError)
|
||
: structuredError
|
||
return {
|
||
lease_token: leaseToken,
|
||
status: 'failed',
|
||
raw_response_json: {
|
||
runtime_error: runtimeError,
|
||
},
|
||
error_message: failureMessage,
|
||
completed_at: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
async function executeLeasedDesktopMonitorTask(
|
||
taskRecord: RuntimeTaskRecord,
|
||
leased: LeaseDesktopTaskResponse,
|
||
routing: RuntimeTaskRouting,
|
||
signal: AbortSignal,
|
||
): Promise<void> {
|
||
const monitoringTaskID = extractMonitoringTaskID(taskRecord.payload)
|
||
if (!monitoringTaskID) {
|
||
const missingMessage = 'monitor desktop task is missing monitoring_task_id'
|
||
const completed = await completeDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token as string,
|
||
status: 'failed',
|
||
error: {
|
||
code: 'monitoring_task_id_missing',
|
||
message: missingMessage,
|
||
},
|
||
})
|
||
upsertTaskFromInfo(completed, {
|
||
routing,
|
||
summary: missingMessage,
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
noteLeaseReleased('failed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity(
|
||
'danger',
|
||
'监控任务执行失败',
|
||
`${taskRecord.title} 缺少 monitoring_task_id,无法回写结果。`,
|
||
)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const execution =
|
||
(await resolveAuthorizationPreflightBlock(taskRecord)) ??
|
||
withAuthorizationRetryPolicy(taskRecord, await executeTaskAdapter(taskRecord, signal))
|
||
if (execution.status === 'unknown') {
|
||
const skipReason = monitoringSkipReasonFromExecution(execution)
|
||
await skipMonitoringTask(monitoringTaskID, {
|
||
lease_token: leased.lease_token as string,
|
||
skip_reason: skipReason,
|
||
error_message: execution.summary,
|
||
completed_at: new Date().toISOString(),
|
||
})
|
||
if (skipReason === 'stale_business_date') {
|
||
const canceled = await cancelDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token as string,
|
||
reason: skipReason,
|
||
}).catch((cancelError) => {
|
||
const existing = state.tasks.get(taskRecord.id)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.error = {
|
||
...(execution.error ?? {}),
|
||
cancel_error: errorMessage(cancelError),
|
||
dropped_reason: 'stale_business_date',
|
||
}
|
||
existing.summary = `${execution.summary}(desktop task 取消失败:${errorMessage(cancelError)})`
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskRecord.id, existing)
|
||
}
|
||
return null
|
||
})
|
||
if (canceled) {
|
||
upsertTaskFromInfo(canceled, {
|
||
routing,
|
||
summary: execution.summary,
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
}
|
||
noteLeaseReleased('completed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity(
|
||
'info',
|
||
'监控任务已按跨日策略丢弃',
|
||
`${taskRecord.title} ${execution.summary}`,
|
||
)
|
||
return
|
||
}
|
||
const completed = await completeDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token as string,
|
||
status: 'unknown',
|
||
error: execution.error ?? {
|
||
code: 'runtime_unknown',
|
||
message: execution.summary,
|
||
},
|
||
})
|
||
upsertTaskFromInfo(completed, {
|
||
routing,
|
||
summary: execution.summary,
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
noteLeaseReleased('completed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity('warn', '监控任务结果待确认', `${taskRecord.title} ${execution.summary}`)
|
||
return
|
||
}
|
||
|
||
await submitMonitoringTaskResult(
|
||
monitoringTaskID,
|
||
buildMonitoringResultPayload(leased.lease_token as string, execution),
|
||
)
|
||
|
||
const existing = state.tasks.get(taskRecord.id)
|
||
if (existing) {
|
||
existing.status = execution.status
|
||
existing.summary = execution.summary
|
||
existing.result = execution.payload ?? null
|
||
existing.error = execution.error ?? null
|
||
existing.updatedAt = Date.now()
|
||
existing.attemptId = null
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskRecord.id, existing)
|
||
}
|
||
noteLeaseReleased(execution.status === 'failed' ? 'failed' : 'completed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity(
|
||
execution.status === 'failed' ? 'danger' : 'success',
|
||
execution.status === 'failed' ? '监控任务执行失败' : '监控任务执行成功',
|
||
`${taskRecord.title} ${execution.summary}`,
|
||
)
|
||
} catch (error) {
|
||
if (isMonitorAbortReason(taskRecord, error, 'stale_business_date')) {
|
||
let dropSummary = staleMonitoringDropSummary(taskRecord)
|
||
try {
|
||
await skipMonitoringTask(monitoringTaskID, {
|
||
lease_token: leased.lease_token as string,
|
||
skip_reason: 'stale_business_date',
|
||
error_message: dropSummary,
|
||
completed_at: new Date().toISOString(),
|
||
})
|
||
} catch (skipError) {
|
||
dropSummary = `${dropSummary}(monitoring skip 回写失败:${errorMessage(skipError)})`
|
||
}
|
||
|
||
const canceled = await cancelDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token as string,
|
||
reason: 'stale_business_date',
|
||
}).catch((cancelError) => {
|
||
const existing = state.tasks.get(taskRecord.id)
|
||
if (existing) {
|
||
existing.status = 'aborted'
|
||
existing.error = {
|
||
...toStructuredError(error),
|
||
cancel_error: errorMessage(cancelError),
|
||
dropped_reason: 'stale_business_date',
|
||
}
|
||
existing.summary = `${dropSummary}(desktop task 取消失败:${errorMessage(cancelError)})`
|
||
existing.updatedAt = Date.now()
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskRecord.id, existing)
|
||
}
|
||
return null
|
||
})
|
||
|
||
if (canceled) {
|
||
upsertTaskFromInfo(canceled, {
|
||
routing,
|
||
summary: dropSummary,
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
}
|
||
noteLeaseReleased('completed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity('info', '监控任务已按跨日策略丢弃', dropSummary)
|
||
return
|
||
}
|
||
|
||
if (isDesktopTaskMonitorInterrupt(taskRecord, error)) {
|
||
const canceled = await cancelDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token as string,
|
||
reason: interruptReasonFromTask(taskRecord),
|
||
})
|
||
upsertTaskFromInfo(canceled, {
|
||
routing,
|
||
summary: '已响应 Phase 2 抢占请求,当前监控任务已回队等待 retry。',
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
noteLeaseReleased('completed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity('warn', '监控任务已让路', `${taskRecord.title} 已安全中止,并回队为 retry。`)
|
||
return
|
||
}
|
||
|
||
const failureMessage = errorMessage(error)
|
||
const structuredError = toStructuredError(error)
|
||
let monitoringResultWriteError: unknown = null
|
||
|
||
try {
|
||
await submitMonitoringTaskResult(
|
||
monitoringTaskID,
|
||
buildMonitoringFailurePayload(
|
||
leased.lease_token as string,
|
||
failureMessage || '监控任务执行失败。',
|
||
structuredError,
|
||
taskRecord,
|
||
),
|
||
)
|
||
} catch (submitError) {
|
||
monitoringResultWriteError = submitError
|
||
}
|
||
|
||
const failureSummary = monitoringResultWriteError
|
||
? `${failureMessage || '监控任务执行失败。'}(monitoring 失败回传失败:${errorMessage(monitoringResultWriteError)})`
|
||
: failureMessage || '监控任务执行失败。'
|
||
const existing = state.tasks.get(taskRecord.id)
|
||
if (existing) {
|
||
existing.status = 'failed'
|
||
existing.error = structuredError
|
||
existing.summary = failureSummary
|
||
existing.updatedAt = Date.now()
|
||
existing.attemptId = null
|
||
existing.leaseToken = null
|
||
state.tasks.set(taskRecord.id, existing)
|
||
}
|
||
noteLeaseReleased('failed', taskRecord.id)
|
||
noteMonitorTaskCompleted(taskRecord.id)
|
||
recordActivity(
|
||
'danger',
|
||
'监控任务执行失败',
|
||
`${taskRecord.title} 执行失败:${failureSummary || '未知错误'}`,
|
||
)
|
||
}
|
||
}
|
||
|
||
function extractMonitoringTaskID(payload: Record<string, JsonValue>): number | null {
|
||
const raw = payload.monitoring_task_id
|
||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||
return raw
|
||
}
|
||
if (typeof raw === 'string' && raw.trim()) {
|
||
const parsed = Number.parseInt(raw.trim(), 10)
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function noteMonitorExecutionSafePoint(taskId: string, phase: MonitorExecutionPhase | null): void {
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (!active || active.kind !== 'monitor') {
|
||
return
|
||
}
|
||
|
||
if (phase) {
|
||
active.executionPhase = phase
|
||
}
|
||
active.lastSafePointAt = Date.now()
|
||
active.lastProgressAt = Date.now()
|
||
state.activeExecutions.set(taskId, active)
|
||
|
||
const taskRecord = state.tasks.get(taskId)
|
||
if (taskRecord?.kind === 'monitor') {
|
||
const staleBusinessDate = resolveStaleMonitoringBusinessDate(taskRecord.payload)
|
||
if (staleBusinessDate) {
|
||
requestMonitorStaleBusinessDateDrop(taskId, staleBusinessDate)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (active.interruptRequested && !active.abortController.signal.aborted) {
|
||
active.abortController.abort()
|
||
}
|
||
}
|
||
|
||
function requestMonitorStaleBusinessDateDrop(taskId: string, staleBusinessDate: string): void {
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (!active || active.kind !== 'monitor') {
|
||
return
|
||
}
|
||
|
||
active.interruptReason = 'stale_business_date'
|
||
active.lastSafePointAt = Date.now()
|
||
active.lastProgressAt = Date.now()
|
||
state.activeExecutions.set(taskId, active)
|
||
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.payload = {
|
||
...existing.payload,
|
||
interrupt_reason: 'stale_business_date',
|
||
dropped_reason: 'stale_business_date',
|
||
}
|
||
existing.summary = staleMonitoringDropSummary(existing, staleBusinessDate)
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
|
||
if (!active.abortController.signal.aborted) {
|
||
active.abortController.abort()
|
||
}
|
||
}
|
||
|
||
function monitorExecutionPhaseFromStage(stage: string): MonitorExecutionPhase {
|
||
const normalized = stage.trim().toLowerCase()
|
||
if (!normalized) {
|
||
return 'WAITING'
|
||
}
|
||
if (normalized.includes('bootstrap') || normalized.includes('page_ready')) {
|
||
return 'NAVIGATING'
|
||
}
|
||
if (normalized.includes('login') || normalized.includes('runtime_state')) {
|
||
return 'AUTHENTICATING'
|
||
}
|
||
if (normalized.includes('input')) {
|
||
return 'INPUTTING'
|
||
}
|
||
if (normalized.includes('query') || normalized.includes('submit')) {
|
||
return 'SUBMITTING'
|
||
}
|
||
if (normalized.includes('parse')) {
|
||
return 'PARSING'
|
||
}
|
||
return 'WAITING'
|
||
}
|
||
|
||
function isDesktopTaskMonitorInterrupt(taskRecord: RuntimeTaskRecord, error: unknown): boolean {
|
||
return (
|
||
isDesktopTaskID(taskRecord.id) && isMonitorAbortReason(taskRecord, error, 'collect_now_preempt')
|
||
)
|
||
}
|
||
|
||
function isMonitorAbortReason(
|
||
taskRecord: RuntimeTaskRecord,
|
||
error: unknown,
|
||
reason: string,
|
||
): boolean {
|
||
return (
|
||
errorMessage(error).includes('adapter_aborted') && monitorAbortReason(taskRecord) === reason
|
||
)
|
||
}
|
||
|
||
function monitorAbortReason(taskRecord: RuntimeTaskRecord): string | null {
|
||
const active = state.activeExecutions.get(taskRecord.id)
|
||
if (active?.interruptReason?.trim()) {
|
||
return active.interruptReason.trim()
|
||
}
|
||
|
||
const raw = taskRecord.payload.interrupt_reason
|
||
if (typeof raw === 'string' && raw.trim()) {
|
||
return raw.trim()
|
||
}
|
||
|
||
if (Boolean(taskRecord.payload.interrupt_requested) || Boolean(active?.interruptRequested)) {
|
||
return 'collect_now_preempt'
|
||
}
|
||
return null
|
||
}
|
||
|
||
function interruptReasonFromTask(taskRecord: RuntimeTaskRecord): string {
|
||
return monitorAbortReason(taskRecord) ?? 'collect_now_preempt'
|
||
}
|
||
|
||
function isDesktopTaskID(taskId: string): boolean {
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||
taskId.trim(),
|
||
)
|
||
}
|
||
|
||
async function executeLeasedTask(
|
||
leased: LeaseDesktopTaskResponse,
|
||
routing: RuntimeTaskRouting,
|
||
): Promise<void> {
|
||
if (!leased.task || !leased.lease_token) {
|
||
return
|
||
}
|
||
|
||
const task = leased.task
|
||
|
||
setSchedulerPhase('running')
|
||
|
||
setActiveLease({
|
||
taskId: task.id,
|
||
attemptId: leased.attempt_id ?? null,
|
||
leaseExpiresAt: leased.lease_expires_at ?? task.lease_expires_at ?? null,
|
||
})
|
||
|
||
const taskRecord = upsertTaskFromInfo(task, {
|
||
routing,
|
||
attemptId: leased.attempt_id ?? null,
|
||
leaseToken: leased.lease_token,
|
||
summary: `已领取租约,开始执行 ${routing === 'rabbitmq-primary' ? 'MQ 唤醒' : '兜底拉取'} 消费。`,
|
||
})
|
||
|
||
recordActivity(
|
||
'info',
|
||
'任务已领取',
|
||
`${taskRecord.title} 已由当前客户端领取,路由:${describeRouting(routing)}。`,
|
||
)
|
||
|
||
const abortController = new AbortController()
|
||
const activeExecution = createActiveExecution(taskRecord, abortController)
|
||
armPublishTaskDeadline(taskRecord, activeExecution)
|
||
state.activeExecutions.set(taskRecord.id, activeExecution)
|
||
|
||
if (task.kind === 'monitor') {
|
||
noteMonitorTaskLeased(task, routing)
|
||
} else {
|
||
notePublishTaskActivated(taskRecord.id, taskRecord.platform)
|
||
emitRuntimeInvalidated('publish-task-lease', { taskId: taskRecord.id })
|
||
}
|
||
syncSchedulerSurface()
|
||
queueMicrotask(() => pumpExecutionLoop())
|
||
|
||
const extendHandle = setInterval(() => {
|
||
void extendActiveLease(taskRecord.id, leased.lease_token as string)
|
||
}, leaseExtendIntervalMs)
|
||
|
||
try {
|
||
if (task.kind === 'monitor') {
|
||
await executeLeasedDesktopMonitorTask(taskRecord, leased, routing, abortController.signal)
|
||
return
|
||
}
|
||
|
||
const execution =
|
||
(await resolveAuthorizationPreflightBlock(taskRecord)) ??
|
||
withAuthorizationRetryPolicy(
|
||
taskRecord,
|
||
await executeTaskAdapter(taskRecord, abortController.signal),
|
||
)
|
||
const completed = await completeDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token,
|
||
status: execution.status,
|
||
payload: execution.payload,
|
||
error: execution.error,
|
||
})
|
||
|
||
noteLeaseReleased(execution.status === 'failed' ? 'failed' : 'completed', taskRecord.id)
|
||
upsertTaskFromInfo(completed, {
|
||
routing,
|
||
summary: execution.summary,
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
|
||
recordActivity(
|
||
execution.status === 'failed'
|
||
? 'danger'
|
||
: execution.status === 'unknown'
|
||
? 'warn'
|
||
: 'success',
|
||
execution.status === 'failed'
|
||
? '任务执行失败'
|
||
: execution.status === 'unknown'
|
||
? '任务执行结果待确认'
|
||
: '任务执行成功',
|
||
`${taskRecord.title} ${execution.summary}`,
|
||
)
|
||
} catch (error) {
|
||
const failureMessage = errorMessage(error)
|
||
const structuredError = toStructuredError(error)
|
||
const accountIdentity = accountIdentityFromTask(taskRecord)
|
||
|
||
// If a publish task was interrupted AFTER it may have entered the irreversible submit phase,
|
||
// the article might already be live on the platform. Completing as a clean 'failed' would drop
|
||
// it from the server dedup set and let a retry double-post. Complete as 'unknown' instead so it
|
||
// stays for manual reconcile. (A pre-submit interruption is a true failure and stays 'failed'.)
|
||
const submitMaybeStarted =
|
||
taskRecord.kind === 'publish' && activeExecution.submitStartedAt !== null
|
||
const terminalStatus: 'failed' | 'unknown' = submitMaybeStarted ? 'unknown' : 'failed'
|
||
const interruptedSummary = '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。'
|
||
// Tag the persisted error so the desktop UI can require explicit confirmation before a manual
|
||
// retry of a possibly-already-published task (mirrors the server recovery payload marker).
|
||
const terminalError = submitMaybeStarted
|
||
? { ...structuredError, publish_submit_uncertain: true }
|
||
: structuredError
|
||
|
||
// A submit-phase interruption is typically a network/timeout stall, not an auth problem — don't
|
||
// wrongly degrade account health for it.
|
||
if (accountIdentity && !submitMaybeStarted) {
|
||
await reportAccountFailure(accountIdentity, {
|
||
summary: failureMessage,
|
||
error: structuredError,
|
||
}).catch((reportError) => {
|
||
console.warn('[desktop-runtime] thrown failure report failed', {
|
||
taskId: taskRecord.id,
|
||
accountId: accountIdentity.id,
|
||
platform: accountIdentity.platform,
|
||
message: reportError instanceof Error ? reportError.message : String(reportError),
|
||
})
|
||
})
|
||
}
|
||
|
||
try {
|
||
const completed = await completeDesktopTask(taskRecord.id, {
|
||
lease_token: leased.lease_token,
|
||
status: terminalStatus,
|
||
error: terminalError,
|
||
})
|
||
|
||
upsertTaskFromInfo(completed, {
|
||
routing,
|
||
summary: submitMaybeStarted ? interruptedSummary : failureMessage || '桌面任务执行失败。',
|
||
attemptId: null,
|
||
leaseToken: null,
|
||
})
|
||
} catch (completeError) {
|
||
const existing = state.tasks.get(taskRecord.id)
|
||
if (existing) {
|
||
existing.status = terminalStatus
|
||
existing.error = terminalError
|
||
existing.summary = submitMaybeStarted
|
||
? `${interruptedSummary}(result 回写失败:${errorMessage(completeError)})`
|
||
: `${failureMessage || '桌面任务执行失败。'}(result 回写失败:${errorMessage(completeError)})`
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskRecord.id, existing)
|
||
}
|
||
}
|
||
|
||
noteLeaseReleased('failed', taskRecord.id)
|
||
recordActivity(
|
||
submitMaybeStarted ? 'warn' : 'danger',
|
||
submitMaybeStarted ? '发布结果待确认' : '任务执行失败',
|
||
submitMaybeStarted
|
||
? `${taskRecord.title} 进入提交阶段后中断,可能已发布,已转人工确认以避免重复发文。`
|
||
: `${taskRecord.title} 执行失败:${failureMessage || '未知错误'}`,
|
||
)
|
||
} finally {
|
||
clearInterval(extendHandle)
|
||
if (activeExecution.deadlineHandle) {
|
||
clearTimeout(activeExecution.deadlineHandle)
|
||
activeExecution.deadlineHandle = null
|
||
}
|
||
if (taskRecord.kind === 'publish') {
|
||
notePublishTaskCompleted(taskRecord.id, taskRecord.platform)
|
||
}
|
||
state.activeExecutions.delete(taskRecord.id)
|
||
syncSchedulerSurface()
|
||
noteSchedulerTaskFinished()
|
||
pumpExecutionLoop()
|
||
}
|
||
}
|
||
|
||
async function extendActiveLease(taskId: string, leaseToken: string): Promise<void> {
|
||
const existing = state.tasks.get(taskId)
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (existing?.kind === 'monitor') {
|
||
const staleBusinessDate = resolveStaleMonitoringBusinessDate(existing.payload)
|
||
if (staleBusinessDate) {
|
||
requestMonitorStaleBusinessDateDrop(taskId, staleBusinessDate)
|
||
return
|
||
}
|
||
}
|
||
if (existing?.kind === 'publish' && active) {
|
||
const idleMs = Date.now() - active.lastProgressAt
|
||
if (idleMs >= publishTaskStaleProgressMs) {
|
||
const detail = active.lastProgressSummary ? `,最后进度:${active.lastProgressSummary}` : ''
|
||
existing.summary = `发布任务超过 ${Math.round(idleMs / 1000)} 秒没有新进度,已中止并释放,准备由服务端重排${detail}。`
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
recordActivity('warn', '发布任务疑似卡住', `${existing.title} ${existing.summary}`)
|
||
// Abort the local execution instead of merely letting the lease lapse: this
|
||
// releases the slot immediately and, crucially, closes the window where the
|
||
// server could re-queue the task while a stalled adapter is still mid-publish
|
||
// (which would double-post a non-idempotent article).
|
||
if (!active.abortController.signal.aborted) {
|
||
active.abortController.abort(new Error('publish_stale_progress'))
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
try {
|
||
const task = await extendDesktopTask(taskId, { lease_token: leaseToken })
|
||
|
||
noteLeaseExtended(task.lease_expires_at ?? null, taskId)
|
||
|
||
const existing = state.tasks.get(taskId)
|
||
if (existing) {
|
||
existing.leaseExpiresAt = parseTimestamp(task.lease_expires_at) ?? existing.leaseExpiresAt
|
||
existing.updatedAt = Date.now()
|
||
existing.summary =
|
||
existing.kind === 'publish'
|
||
? (active?.lastProgressSummary ?? '租约已自动续期,发布任务仍在执行。')
|
||
: '租约已自动续期,任务仍在执行。'
|
||
state.tasks.set(taskId, existing)
|
||
}
|
||
} catch (error) {
|
||
const message = errorMessage(error)
|
||
state.lastError = message
|
||
setSchedulerError(message)
|
||
recordActivity('warn', '租约续期失败', `${taskId} 续租失败:${message}`)
|
||
}
|
||
}
|
||
|
||
async function executeTaskAdapter(
|
||
task: RuntimeTaskRecord,
|
||
signal: AbortSignal,
|
||
): Promise<AdapterExecutionResult> {
|
||
const accountIdentity = accountIdentityFromTask(task)
|
||
if (accountIdentity && shouldEnforceActiveAccount(task)) {
|
||
const readiness = await ensureAccountReady(accountIdentity)
|
||
if (readiness.authState !== 'active') {
|
||
return buildAccountBlockedResult(task, readiness)
|
||
}
|
||
}
|
||
|
||
const sessionHandle = createSessionHandle(task.accountId || task.id)
|
||
const payload = task.payload
|
||
|
||
if (task.kind === 'publish') {
|
||
const adapter = selectPublishAdapter(task.platform)
|
||
if (!adapter) {
|
||
return buildScaffoldResult(task, payload, 'publish adapter is not implemented yet')
|
||
}
|
||
|
||
const viewHandle =
|
||
adapter.executionMode === 'session' || adapter.executionMode === 'playwright'
|
||
? null
|
||
: retainHotView(task.accountId || task.id)
|
||
const playwrightLease =
|
||
adapter.executionMode === 'playwright'
|
||
? await retainHiddenPlaywrightPage({
|
||
accountId: task.accountId || task.id,
|
||
session: sessionHandle.session,
|
||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||
title: `${task.title} · Hidden Playwright`,
|
||
})
|
||
: null
|
||
|
||
try {
|
||
const result = await adapter.publish(
|
||
{
|
||
taskId: task.id,
|
||
accountId: task.accountId || task.id,
|
||
session: sessionHandle.session,
|
||
view: viewHandle?.view ?? null,
|
||
playwright: playwrightLease
|
||
? {
|
||
browser: playwrightLease.browser,
|
||
page: playwrightLease.page,
|
||
}
|
||
: null,
|
||
signal,
|
||
phase: 'initial',
|
||
article: await loadPublishArticle(task),
|
||
async reportProgress(stage: string) {
|
||
noteMonitorExecutionSafePoint(task.id, null)
|
||
updateTaskProgress(task.id, `publish adapter progress: ${stage}`)
|
||
await markPublishSubmitStartedIfNeeded(task.id, stage)
|
||
},
|
||
},
|
||
payload,
|
||
)
|
||
const active = state.activeExecutions.get(task.id)
|
||
const normalizedResult =
|
||
active?.submitStartedAt != null ? asSubmitUncertainExecution(result) : result
|
||
await maybeReportTaskAuthFailure(task, normalizedResult, accountIdentity)
|
||
return normalizedResult
|
||
} finally {
|
||
await playwrightLease?.release()
|
||
if (viewHandle) {
|
||
releaseHotView(viewHandle.accountId)
|
||
}
|
||
}
|
||
}
|
||
|
||
const staleBusinessDate = resolveStaleMonitoringBusinessDate(payload)
|
||
if (staleBusinessDate) {
|
||
return {
|
||
status: 'unknown',
|
||
payload: {
|
||
...payload,
|
||
dropped_by_client: true,
|
||
dropped_reason: 'stale_business_date',
|
||
},
|
||
error: {
|
||
code: 'desktop_monitor_task_stale',
|
||
message: 'monitor task is stale and has been dropped by desktop client',
|
||
business_date: staleBusinessDate,
|
||
},
|
||
summary: `${task.title} 已过业务日 ${staleBusinessDate},按漏采策略直接丢弃。`,
|
||
}
|
||
}
|
||
|
||
const adapter = selectMonitorAdapter(task.platform)
|
||
if (!adapter) {
|
||
return buildScaffoldResult(task, payload, 'monitor adapter is not implemented yet')
|
||
}
|
||
|
||
const viewHandle =
|
||
adapter.executionMode === 'session' || adapter.executionMode === 'playwright'
|
||
? null
|
||
: retainHotView(task.accountId || task.id)
|
||
const playwrightLease =
|
||
adapter.executionMode === 'playwright'
|
||
? await retainHiddenPlaywrightPage({
|
||
accountId: task.accountId || task.id,
|
||
session: sessionHandle.session,
|
||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||
title: `${task.title} · Hidden Playwright`,
|
||
})
|
||
: null
|
||
|
||
try {
|
||
const result = await adapter.query(
|
||
{
|
||
taskId: task.id,
|
||
accountId: task.accountId || task.id,
|
||
session: sessionHandle.session,
|
||
view: viewHandle?.view ?? null,
|
||
playwright: playwrightLease
|
||
? {
|
||
browser: playwrightLease.browser,
|
||
page: playwrightLease.page,
|
||
}
|
||
: null,
|
||
signal,
|
||
phase: 'initial',
|
||
reportProgress(stage: string) {
|
||
noteMonitorExecutionSafePoint(task.id, monitorExecutionPhaseFromStage(stage))
|
||
updateTaskProgress(task.id, `monitor adapter progress: ${stage}`)
|
||
},
|
||
},
|
||
payload,
|
||
)
|
||
await maybeReportTaskAuthFailure(task, result, accountIdentity)
|
||
return result
|
||
} finally {
|
||
await playwrightLease?.release()
|
||
if (viewHandle) {
|
||
releaseHotView(viewHandle.accountId)
|
||
}
|
||
}
|
||
}
|
||
|
||
function shouldEnforceActiveAccount(task: RuntimeTaskRecord): boolean {
|
||
if (task.kind === 'publish') {
|
||
return true
|
||
}
|
||
|
||
if (!isAIPlatformId(task.platform)) {
|
||
return true
|
||
}
|
||
|
||
return authRequiredMonitorPlatforms.has(task.platform)
|
||
}
|
||
|
||
function buildAccountBlockedResult(
|
||
task: RuntimeTaskRecord,
|
||
readiness: Awaited<ReturnType<typeof ensureAccountReady>>,
|
||
): AdapterExecutionResult {
|
||
if (readiness.authState === 'challenge_required') {
|
||
return {
|
||
status: 'failed',
|
||
summary: accountActionRequiredSummary(task.platform, readiness.authReason),
|
||
error: markAuthorizationFailureNonRetryable(
|
||
{
|
||
code:
|
||
readiness.authReason === 'risk_control'
|
||
? 'desktop_account_risk_control'
|
||
: 'desktop_account_challenge_required',
|
||
message:
|
||
readiness.authReason === 'risk_control'
|
||
? 'desktop account risk control triggered'
|
||
: 'desktop account challenge required',
|
||
},
|
||
isAIPlatformId(task.platform) ? 'ai_platform_authorization' : 'media_account_authorization',
|
||
),
|
||
}
|
||
}
|
||
|
||
if (readiness.authState === 'expired' || readiness.authState === 'revoked') {
|
||
return {
|
||
status: 'failed',
|
||
summary: `${task.accountName} 登录态已失效,请重新授权后再试。`,
|
||
error: markAuthorizationFailureNonRetryable(
|
||
{
|
||
code: 'desktop_account_auth_expired',
|
||
message: 'desktop account auth expired',
|
||
},
|
||
isAIPlatformId(task.platform) ? 'ai_platform_authorization' : 'media_account_authorization',
|
||
),
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: 'unknown',
|
||
summary: `${task.accountName} 最近校验失败,已暂缓执行任务。`,
|
||
error: {
|
||
code: 'desktop_account_probe_pending',
|
||
message: 'desktop account readiness pending',
|
||
},
|
||
}
|
||
}
|
||
|
||
async function maybeReportTaskAuthFailure(
|
||
task: RuntimeTaskRecord,
|
||
result: AdapterExecutionResult,
|
||
accountIdentity: PublishAccountIdentity | null,
|
||
): Promise<void> {
|
||
if (!accountIdentity) {
|
||
return
|
||
}
|
||
|
||
const previousSnapshot = getAccountHealthSnapshot(accountIdentity.id)
|
||
const nextSnapshot = await reportAccountFailure(accountIdentity, {
|
||
summary: result.summary,
|
||
error: result.error ?? null,
|
||
}).catch((error) => {
|
||
console.warn('[desktop-runtime] account failure report failed', {
|
||
taskId: task.id,
|
||
accountId: accountIdentity.id,
|
||
platform: accountIdentity.platform,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
})
|
||
return null
|
||
})
|
||
|
||
maybeRecordAccountAccessAlert(task, accountIdentity, previousSnapshot, nextSnapshot)
|
||
if (nextSnapshot && nextSnapshot.authState !== 'active') {
|
||
enqueueAccountHealthReport(accountIdentity.id)
|
||
}
|
||
}
|
||
|
||
function resolvePlaywrightTargetURL(platform: string, payload: Record<string, JsonValue>): string {
|
||
const payloadURL = resolveStringField(payload, [
|
||
'target_url',
|
||
'targetUrl',
|
||
'console_url',
|
||
'consoleUrl',
|
||
'url',
|
||
])
|
||
if (payloadURL) {
|
||
return payloadURL
|
||
}
|
||
|
||
const aiPlatform = getAIPlatformCatalogItem(platform)
|
||
if (aiPlatform?.consoleUrl) {
|
||
return aiPlatform.consoleUrl
|
||
}
|
||
|
||
return 'about:blank'
|
||
}
|
||
|
||
function buildScaffoldResult(
|
||
task: RuntimeTaskRecord,
|
||
adapterPayload: Record<string, JsonValue>,
|
||
detail: string,
|
||
): AdapterExecutionResult {
|
||
return {
|
||
status: 'failed',
|
||
payload: {
|
||
...adapterPayload,
|
||
runtime_mode: 'desktop-scaffold',
|
||
task_kind: task.kind,
|
||
task_platform: task.platform,
|
||
},
|
||
error: {
|
||
code: 'desktop_task_adapter_missing',
|
||
message: 'desktop runtime adapter is not implemented for this platform',
|
||
detail,
|
||
},
|
||
summary: `${task.title} 执行失败:当前平台的 desktop ${task.kind === 'publish' ? '发布' : '监测'}适配器尚未实现。`,
|
||
}
|
||
}
|
||
|
||
async function loadPublishArticle(task: RuntimeTaskRecord): Promise<DesktopArticleContent> {
|
||
const articleId = resolveArticleId(task.payload)
|
||
if (articleId === null) {
|
||
throw new Error('desktop_publish_article_id_missing')
|
||
}
|
||
|
||
const article = await getDesktopArticleContent(articleId)
|
||
return {
|
||
...article,
|
||
title: article.title?.trim() || task.title,
|
||
}
|
||
}
|
||
|
||
function resolveArticleId(payload: Record<string, JsonValue>): number | null {
|
||
const direct = toPositiveInt(payload.article_id)
|
||
if (direct !== null) {
|
||
return direct
|
||
}
|
||
|
||
const contentRef = payload.content_ref
|
||
if (!contentRef || typeof contentRef !== 'object' || Array.isArray(contentRef)) {
|
||
return null
|
||
}
|
||
|
||
const typed = contentRef as Record<string, JsonValue>
|
||
return toPositiveInt(typed.id ?? typed.article_id ?? null)
|
||
}
|
||
|
||
function toPositiveInt(value: JsonValue | null): number | null {
|
||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
|
||
return value
|
||
}
|
||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||
const parsed = Number.parseInt(value.trim(), 10)
|
||
return parsed > 0 ? parsed : null
|
||
}
|
||
return null
|
||
}
|
||
|
||
function resolveStringField(payload: Record<string, JsonValue>, keys: string[]): string | null {
|
||
for (const key of keys) {
|
||
const value = payload[key]
|
||
if (typeof value !== 'string') {
|
||
continue
|
||
}
|
||
const normalized = value.trim()
|
||
if (normalized) {
|
||
return normalized
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function resolveStaleMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||
const businessDate = resolveMonitoringBusinessDate(payload)
|
||
if (!businessDate) {
|
||
return null
|
||
}
|
||
|
||
return businessDate < currentMonitoringBusinessDate() ? businessDate : null
|
||
}
|
||
|
||
function monitoringSkipReasonFromExecution(execution: AdapterExecutionResult): string {
|
||
return isStaleMonitoringExecutionResult(execution) ? 'stale_business_date' : 'runtime_unknown'
|
||
}
|
||
|
||
function isStaleMonitoringExecutionResult(execution: AdapterExecutionResult): boolean {
|
||
return execution.payload?.dropped_reason === 'stale_business_date'
|
||
}
|
||
|
||
function staleMonitoringDropSummary(
|
||
task: RuntimeTaskRecord,
|
||
staleBusinessDate?: string | null,
|
||
): string {
|
||
const businessDate = staleBusinessDate ?? resolveMonitoringBusinessDate(task.payload)
|
||
if (businessDate) {
|
||
return `${task.title} 已过业务日 ${businessDate},按漏采策略直接丢弃。`
|
||
}
|
||
return `${task.title} 已跨业务日,按漏采策略直接丢弃。`
|
||
}
|
||
|
||
function resolveMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||
const candidates = [
|
||
payload.business_date,
|
||
payload.businessDate,
|
||
payload.metric_date,
|
||
payload.date,
|
||
]
|
||
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== 'string') {
|
||
continue
|
||
}
|
||
const normalized = candidate.trim()
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||
return normalized
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function currentMonitoringBusinessDate(now = new Date()): string {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: monitorBusinessTimeZone,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
}).formatToParts(now)
|
||
|
||
const year = parts.find((part) => part.type === 'year')?.value ?? ''
|
||
const month = parts.find((part) => part.type === 'month')?.value ?? ''
|
||
const day = parts.find((part) => part.type === 'day')?.value ?? ''
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
function updateTaskProgress(taskId: string, summary: string): void {
|
||
const existing = state.tasks.get(taskId)
|
||
if (!existing) {
|
||
return
|
||
}
|
||
|
||
existing.summary = summary
|
||
existing.updatedAt = Date.now()
|
||
state.tasks.set(taskId, existing)
|
||
|
||
const active = state.activeExecutions.get(taskId)
|
||
if (active) {
|
||
active.lastProgressAt = Date.now()
|
||
active.lastProgressSummary = summary
|
||
state.activeExecutions.set(taskId, active)
|
||
}
|
||
|
||
if (existing.kind === 'publish') {
|
||
emitRuntimeInvalidated('publish-task-progress', { taskId })
|
||
}
|
||
}
|
||
|
||
function upsertTaskFromInfo(
|
||
task: DesktopTaskInfo,
|
||
options: {
|
||
routing?: RuntimeTaskRouting
|
||
summary?: string
|
||
attemptId?: string | null
|
||
leaseToken?: string | null
|
||
} = {},
|
||
): RuntimeTaskRecord {
|
||
const existing = state.tasks.get(task.id)
|
||
const payload = normalizeJsonObject(task.payload)
|
||
const record: RuntimeTaskRecord = {
|
||
id: task.id,
|
||
jobId: task.job_id,
|
||
title: resolveTaskTitle(task, payload, existing),
|
||
kind: task.kind,
|
||
platform: task.platform,
|
||
accountId: task.target_account_id,
|
||
accountName: resolveAccountName(task.target_account_id, existing?.accountName),
|
||
clientId: task.target_client_id,
|
||
status: task.status,
|
||
routing: options.routing ?? existing?.routing ?? 'db-recovery',
|
||
leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null,
|
||
startedAt: task.status === 'in_progress' ? (existing?.startedAt ?? Date.now()) : null,
|
||
updatedAt: parseTimestamp(task.updated_at) ?? Date.now(),
|
||
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status),
|
||
payload,
|
||
error: normalizeJsonObjectOrNull(task.error),
|
||
result: normalizeJsonObjectOrNull(task.result),
|
||
attemptId:
|
||
task.status === 'in_progress'
|
||
? (options.attemptId ?? existing?.attemptId ?? task.active_attempt_id ?? null)
|
||
: null,
|
||
leaseToken:
|
||
task.status === 'in_progress' ? (options.leaseToken ?? existing?.leaseToken ?? null) : null,
|
||
}
|
||
|
||
state.tasks.set(task.id, record)
|
||
return record
|
||
}
|
||
|
||
function refreshAccountNames(): void {
|
||
for (const [taskId, task] of state.tasks.entries()) {
|
||
const nextName = resolveAccountName(task.accountId, task.accountName)
|
||
if (nextName === task.accountName) {
|
||
continue
|
||
}
|
||
state.tasks.set(taskId, { ...task, accountName: nextName })
|
||
}
|
||
}
|
||
|
||
function recordActivity(severity: RuntimeTone, title: string, detail: string): void {
|
||
const next: RuntimeControllerActivity = {
|
||
id: `rt-${Date.now()}-${(state.activitySeq += 1)}`,
|
||
severity,
|
||
title,
|
||
detail,
|
||
at: Date.now(),
|
||
}
|
||
|
||
state.activity.unshift(next)
|
||
if (state.activity.length > maxActivityItems) {
|
||
state.activity.length = maxActivityItems
|
||
}
|
||
}
|
||
|
||
function syncSchedulerSurface(): void {
|
||
setSchedulerQueueDepth(localQueueDepth())
|
||
setSchedulerCurrentTask(primaryActiveTaskId())
|
||
}
|
||
|
||
function notifyLongRunningPublishTasks(): void {
|
||
const now = Date.now()
|
||
for (const active of state.activeExecutions.values()) {
|
||
if (active.kind !== 'publish' || active.longRunningNotifiedAt) {
|
||
continue
|
||
}
|
||
if (now - active.startedAt < publishLongRunningNotificationMs) {
|
||
continue
|
||
}
|
||
active.longRunningNotifiedAt = now
|
||
state.activeExecutions.set(active.taskId, active)
|
||
|
||
const task = state.tasks.get(active.taskId)
|
||
if (task) {
|
||
task.summary = `${task.summary || '发布任务仍在执行。'}(已执行 ${formatDurationText(now - active.startedAt)},如网络异常可取消后重试。)`
|
||
task.updatedAt = now
|
||
state.tasks.set(task.id, task)
|
||
recordActivity(
|
||
'warn',
|
||
'发布等待较久',
|
||
`${task.title} 已执行 ${formatDurationText(now - active.startedAt)},可能存在网络异常。`,
|
||
)
|
||
emitRuntimeInvalidated('publish-task-progress', { taskId: task.id })
|
||
}
|
||
}
|
||
}
|
||
|
||
function formatDurationText(ms: number): string {
|
||
const seconds = Math.max(0, Math.floor(ms / 1000))
|
||
const minutes = Math.floor(seconds / 60)
|
||
const restSeconds = seconds % 60
|
||
if (minutes <= 0) {
|
||
return `${restSeconds} 秒`
|
||
}
|
||
if (restSeconds === 0) {
|
||
return `${minutes} 分钟`
|
||
}
|
||
return `${minutes} 分 ${restSeconds} 秒`
|
||
}
|
||
|
||
function localQueueDepth(): number {
|
||
const publish = getPublishSchedulerSnapshot()
|
||
const monitor = getMonitorSchedulerSnapshot()
|
||
return publish.queueDepth + monitor.queuedCount + monitor.leasingCount
|
||
}
|
||
|
||
function primaryActiveTaskId(): string | null {
|
||
const active = [...state.activeExecutions.values()]
|
||
const publish = active.find((item) => item.kind === 'publish') ?? null
|
||
return publish?.taskId ?? active[0]?.taskId ?? null
|
||
}
|
||
|
||
function isLeaseInFlight(kind: 'publish' | 'monitor'): boolean {
|
||
return state.leaseInFlightKinds.has(kind)
|
||
}
|
||
|
||
function activePublishCount(): number {
|
||
return [...state.activeExecutions.values()].filter((item) => item.kind === 'publish').length
|
||
}
|
||
|
||
function activePublishPlatforms(): Set<string> {
|
||
const platforms = new Set(
|
||
[...state.activeExecutions.values()]
|
||
.filter((item) => item.kind === 'publish')
|
||
.map((item) => item.platform),
|
||
)
|
||
for (const task of getPublishSchedulerSnapshot().tasks) {
|
||
if (task.state === 'active' || task.state === 'leasing') {
|
||
platforms.add(task.platform)
|
||
}
|
||
}
|
||
return platforms
|
||
}
|
||
|
||
function activeMonitorCount(): number {
|
||
return [...state.activeExecutions.values()].filter((item) => item.kind === 'monitor').length
|
||
}
|
||
|
||
function activeMonitorPlatforms(): Set<string> {
|
||
const platforms = new Set(
|
||
[...state.activeExecutions.values()]
|
||
.filter((item) => item.kind === 'monitor')
|
||
.map((item) => item.platform),
|
||
)
|
||
for (const task of getMonitorSchedulerSnapshot().tasks) {
|
||
if (task.state === 'active' || task.state === 'leasing') {
|
||
platforms.add(task.platform)
|
||
}
|
||
}
|
||
return platforms
|
||
}
|
||
|
||
function activeMonitorQuestionKeys(): Set<string> {
|
||
return new Set(
|
||
getMonitorSchedulerSnapshot()
|
||
.tasks.filter(
|
||
(task) =>
|
||
(task.state === 'active' || task.state === 'leasing') && Boolean(task.questionKey),
|
||
)
|
||
.map((task) => task.questionKey as string),
|
||
)
|
||
}
|
||
|
||
function hasPublishBacklog(): boolean {
|
||
const snapshot = getPublishSchedulerSnapshot()
|
||
return snapshot.queuedCount + snapshot.leasingCount > 0
|
||
}
|
||
|
||
function activeAdmissionCount(): number {
|
||
const publish = getPublishSchedulerSnapshot()
|
||
const monitor = getMonitorSchedulerSnapshot()
|
||
return (
|
||
state.activeExecutions.size +
|
||
publish.leasingCount +
|
||
monitor.leasingCount +
|
||
state.leaseInFlightKinds.size
|
||
)
|
||
}
|
||
|
||
function hasTotalCapacityForNewExecution(): boolean {
|
||
return (
|
||
activeAdmissionCount() < resolveAdaptiveTotalConcurrency() &&
|
||
shouldAdmitNewExecutionUnderRuntimePressure()
|
||
)
|
||
}
|
||
|
||
function canStartAnotherPublishExecution(): boolean {
|
||
const snapshot = getPublishSchedulerSnapshot()
|
||
return (
|
||
snapshot.queuedCount > 0 && !isLeaseInFlight('publish') && hasTotalCapacityForNewExecution()
|
||
)
|
||
}
|
||
|
||
function shouldPullPublishFallback(): boolean {
|
||
return (
|
||
!state.dispatchWsConnected &&
|
||
Date.now() >= state.publishFallbackBackoffUntil &&
|
||
!isLeaseInFlight('publish') &&
|
||
!hasPublishBacklog() &&
|
||
activePublishCount() === 0 &&
|
||
hasTotalCapacityForNewExecution()
|
||
)
|
||
}
|
||
|
||
function canStartAnotherMonitorExecution(): boolean {
|
||
if (hasPublishBacklog() || isLeaseInFlight('publish') || isLeaseInFlight('monitor')) {
|
||
return false
|
||
}
|
||
const limit = resolveAdaptiveMonitorConcurrency()
|
||
if (limit <= 0) {
|
||
return false
|
||
}
|
||
const scheduler = getMonitorSchedulerSnapshot()
|
||
return activeMonitorCount() + scheduler.leasingCount < limit && hasTotalCapacityForNewExecution()
|
||
}
|
||
|
||
function resolveLegacyMonitoringLeaseLimit(): number {
|
||
if (!canStartAnotherMonitorExecution()) {
|
||
return 0
|
||
}
|
||
|
||
const snapshot = getMonitorSchedulerSnapshot()
|
||
if (snapshot.queuedCount > 0 || snapshot.leasingCount > 0) {
|
||
return 0
|
||
}
|
||
|
||
// Legacy monitoring leases cannot be extended while they sit in the local
|
||
// scheduler, so only pull one when it can start immediately.
|
||
if (activeMonitorCount() > 0) {
|
||
return 0
|
||
}
|
||
|
||
return 1
|
||
}
|
||
|
||
function resolveAdaptiveMonitorConcurrency(): number {
|
||
const backgroundBudget = resolveMonitorBackgroundBudget()
|
||
if (backgroundBudget <= 0) {
|
||
return 0
|
||
}
|
||
|
||
const monitorAccountCount = state.accounts.filter(
|
||
(account) =>
|
||
isAIPlatformId(account.platform) &&
|
||
getProjectedAccountHealth({
|
||
accountId: account.id,
|
||
platform: account.platform,
|
||
health: account.health,
|
||
verifiedAt: account.verified_at,
|
||
}).health === 'live',
|
||
).length
|
||
if (monitorAccountCount <= 1) {
|
||
return Math.min(monitorAccountCount, backgroundBudget)
|
||
}
|
||
|
||
const metrics = getProcessMetricsSnapshot().latestSample
|
||
if (!metrics) {
|
||
return Math.min(1, backgroundBudget)
|
||
}
|
||
|
||
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0)
|
||
const totalPrivateBytesKB = metrics.totals.privateBytes
|
||
const processCount = metrics.totals.processCount
|
||
const mainPrivateBytesKB = metrics.mainProcess.memory?.private ?? 0
|
||
|
||
if (
|
||
totalCPUUsage >= 180 ||
|
||
totalPrivateBytesKB >= 2_500_000 ||
|
||
mainPrivateBytesKB >= 1_000_000 ||
|
||
processCount >= 40
|
||
) {
|
||
return Math.min(1, backgroundBudget)
|
||
}
|
||
|
||
return Math.min(2, backgroundBudget)
|
||
}
|
||
|
||
function resolveMonitorBackgroundBudget(): number {
|
||
if (hasPublishBacklog() || isLeaseInFlight('publish')) {
|
||
return 0
|
||
}
|
||
return Math.max(0, resolveAdaptiveTotalConcurrency() - publishReservedSlots)
|
||
}
|
||
|
||
function resolveAdaptiveTotalConcurrency(): number {
|
||
const hardwareCap = resolveHardwareTotalConcurrency()
|
||
const runtimeCap = resolveRuntimeHealthTotalConcurrency(hardwareCap)
|
||
const featureFlagCap = resolveFeatureFlagTotalConcurrency() ?? maximumRuntimeTotalConcurrency
|
||
return clampInteger(
|
||
Math.min(hardwareCap, runtimeCap, featureFlagCap),
|
||
featureFlagCap <= 1 ? 1 : minimumForegroundTotalConcurrency,
|
||
maximumRuntimeTotalConcurrency,
|
||
)
|
||
}
|
||
|
||
function resolveHardwareTotalConcurrency(): number {
|
||
const cpuCount = Math.max(1, cpus().length)
|
||
const memoryGB = totalmem() / 1024 / 1024 / 1024
|
||
|
||
if (cpuCount >= 10 && memoryGB >= 32) {
|
||
return 4
|
||
}
|
||
if (cpuCount >= 6 && memoryGB >= 16) {
|
||
return 3
|
||
}
|
||
return 2
|
||
}
|
||
|
||
function resolveRuntimeHealthTotalConcurrency(hardwareCap: number): number {
|
||
return hardwareCap
|
||
}
|
||
|
||
function shouldAdmitNewExecutionUnderRuntimePressure(): boolean {
|
||
const metrics = getProcessMetricsSnapshot().latestSample
|
||
if (!metrics) {
|
||
return true
|
||
}
|
||
|
||
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0)
|
||
const totalPrivateBytesKB = metrics.totals.privateBytes
|
||
const processCount = metrics.totals.processCount
|
||
const mainPrivateBytesKB = metrics.mainProcess.memory?.private ?? 0
|
||
|
||
if (
|
||
totalCPUUsage >= 180 ||
|
||
totalPrivateBytesKB >= 2_500_000 ||
|
||
mainPrivateBytesKB >= 1_000_000 ||
|
||
processCount >= 40
|
||
) {
|
||
return activeAdmissionCount() < minimumForegroundTotalConcurrency
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function resolveFeatureFlagTotalConcurrency(): number | null {
|
||
const raw = process.env.GEO_DESKTOP_MAX_TOTAL_CONCURRENCY?.trim()
|
||
if (!raw) {
|
||
return null
|
||
}
|
||
|
||
const parsed = Number.parseInt(raw, 10)
|
||
if (!Number.isFinite(parsed)) {
|
||
return null
|
||
}
|
||
return clampInteger(parsed, 1, maximumRuntimeTotalConcurrency)
|
||
}
|
||
|
||
function clampInteger(value: number, min: number, max: number): number {
|
||
return Math.min(max, Math.max(min, Math.floor(value)))
|
||
}
|
||
|
||
function selectPublishAdapter(platform: string): PublishAdapter | null {
|
||
if (platform === toutiaoAdapter.platform) {
|
||
return toutiaoAdapter
|
||
}
|
||
if (platform === baijiahaoAdapter.platform) {
|
||
return baijiahaoAdapter
|
||
}
|
||
if (platform === sohuhaoAdapter.platform) {
|
||
return sohuhaoAdapter
|
||
}
|
||
if (platform === zhihuAdapter.platform) {
|
||
return zhihuAdapter
|
||
}
|
||
if (platform === wangyihaoAdapter.platform) {
|
||
return wangyihaoAdapter
|
||
}
|
||
if (platform === jianshuAdapter.platform) {
|
||
return jianshuAdapter
|
||
}
|
||
if (platform === juejinAdapter.platform) {
|
||
return juejinAdapter
|
||
}
|
||
if (platform === qiehaoAdapter.platform) {
|
||
return qiehaoAdapter
|
||
}
|
||
if (platform === bilibiliAdapter.platform) {
|
||
return bilibiliAdapter
|
||
}
|
||
if (platform === smzdmAdapter.platform) {
|
||
return smzdmAdapter
|
||
}
|
||
if (platform === weixinGzhAdapter.platform) {
|
||
return weixinGzhAdapter
|
||
}
|
||
if (platform === zolAdapter.platform) {
|
||
return zolAdapter
|
||
}
|
||
if (platform === dongchediAdapter.platform) {
|
||
return dongchediAdapter
|
||
}
|
||
return null
|
||
}
|
||
|
||
function selectMonitorAdapter(platform: string): MonitorAdapter | null {
|
||
return getMonitorAdapter(platform)
|
||
}
|
||
|
||
function normalizeSession(
|
||
session: DesktopRuntimeSessionSyncRequest | null,
|
||
): DesktopRuntimeSessionSyncRequest | null {
|
||
if (!session) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
api_base_url: normalizeDesktopApiBaseURL(session.api_base_url),
|
||
mode: session.mode,
|
||
client_token: session.client_token?.trim() || null,
|
||
desktop_client: session.desktop_client ? { ...session.desktop_client } : null,
|
||
}
|
||
}
|
||
|
||
function sameSession(
|
||
left: DesktopRuntimeSessionSyncRequest | null,
|
||
right: DesktopRuntimeSessionSyncRequest | null,
|
||
): boolean {
|
||
if (!left && !right) {
|
||
return true
|
||
}
|
||
if (!left || !right) {
|
||
return false
|
||
}
|
||
|
||
return (
|
||
left.api_base_url === right.api_base_url &&
|
||
left.mode === right.mode &&
|
||
left.client_token === right.client_token &&
|
||
left.desktop_client?.id === right.desktop_client?.id
|
||
)
|
||
}
|
||
|
||
function canRunLive(
|
||
session: DesktopRuntimeSessionSyncRequest | null,
|
||
): session is DesktopRuntimeSessionSyncRequest {
|
||
return Boolean(
|
||
session && session.mode === 'authenticated' && session.client_token && session.desktop_client,
|
||
)
|
||
}
|
||
|
||
function resolveAccountName(accountId: string, fallback = '待同步账号'): string {
|
||
const account = state.accounts.find((item) => item.id === accountId)
|
||
return account?.display_name ?? fallback
|
||
}
|
||
|
||
function currentClientID(): string {
|
||
return state.client?.id ?? state.session?.desktop_client?.id ?? ''
|
||
}
|
||
|
||
function resolveTaskTitle(
|
||
task: DesktopTaskInfo,
|
||
payload: Record<string, JsonValue>,
|
||
existing?: RuntimeTaskRecord,
|
||
): string {
|
||
const title = typeof payload.title === 'string' ? payload.title.trim() : ''
|
||
if (title) {
|
||
return title
|
||
}
|
||
return existing?.title ?? defaultTaskTitle(task.kind, task.platform)
|
||
}
|
||
|
||
function describeRouting(routing: RuntimeTaskRouting): string {
|
||
return routing === 'rabbitmq-primary' ? '主消息队列通道' : '数据库兜底拉取'
|
||
}
|
||
|
||
function defaultTaskTitle(kind: 'publish' | 'monitor', platform?: string): string {
|
||
return `${kind === 'publish' ? '发布任务' : '监控任务'}${platform ? ` · ${platform}` : ''}`
|
||
}
|
||
|
||
function summaryFromTaskStatus(status: RuntimeTaskStatus): string {
|
||
switch (status) {
|
||
case 'queued':
|
||
return '任务已排队,等待客户端领取租约。'
|
||
case 'in_progress':
|
||
return '任务正在执行,结果回写需要携带有效租约凭证。'
|
||
case 'succeeded':
|
||
return '任务执行成功。'
|
||
case 'failed':
|
||
return '任务执行失败。'
|
||
case 'aborted':
|
||
return '任务已被取消。'
|
||
default:
|
||
return '任务执行异常,已按失败处理。'
|
||
}
|
||
}
|
||
|
||
function summaryFromEventType(
|
||
type: DesktopTaskEventMessage['type'],
|
||
status: RuntimeTaskStatus,
|
||
): string {
|
||
switch (type) {
|
||
case 'task_available':
|
||
return '主消息队列事件已送达当前客户端,等待领取租约。'
|
||
case 'task_leased':
|
||
return '服务端已确认租约归属。'
|
||
case 'task_extended':
|
||
return '任务租约已续期。'
|
||
case 'task_canceled':
|
||
return '任务已被取消。'
|
||
case 'task_reconciled':
|
||
return '任务已完成人工对账并回写。'
|
||
default:
|
||
return summaryFromTaskStatus(status)
|
||
}
|
||
}
|
||
|
||
function inferPlatformFromAccount(accountId: string | null): string {
|
||
if (!accountId) {
|
||
return 'desktop'
|
||
}
|
||
return state.accounts.find((item) => item.id === accountId)?.platform ?? 'desktop'
|
||
}
|
||
|
||
function buildApiUrl(baseURL: string, pathname: string): string {
|
||
const normalized = baseURL.endsWith('/') ? baseURL : `${baseURL}/`
|
||
return new URL(pathname.replace(/^\//, ''), normalized).toString()
|
||
}
|
||
|
||
function normalizeJsonObject(
|
||
value: Record<string, JsonValue> | null | undefined,
|
||
): Record<string, JsonValue> {
|
||
if (!value) {
|
||
return {}
|
||
}
|
||
return { ...value }
|
||
}
|
||
|
||
function normalizeJsonObjectOrNull(
|
||
value: Record<string, JsonValue> | null | undefined,
|
||
): Record<string, JsonValue> | null {
|
||
if (!value) {
|
||
return null
|
||
}
|
||
return { ...value }
|
||
}
|
||
|
||
function asOptionalString(value: unknown): string | undefined {
|
||
if (typeof value !== 'string') {
|
||
return undefined
|
||
}
|
||
const normalized = value.trim()
|
||
return normalized || undefined
|
||
}
|
||
|
||
function asOptionalBoolean(value: unknown): boolean | undefined {
|
||
return typeof value === 'boolean' ? value : undefined
|
||
}
|
||
|
||
function asOptionalNumber(value: unknown): number | undefined {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||
}
|
||
|
||
function asStringArray(value: unknown): string[] | undefined {
|
||
if (!Array.isArray(value)) {
|
||
return undefined
|
||
}
|
||
const normalized = value
|
||
.filter((item): item is string => typeof item === 'string')
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length > 0)
|
||
return normalized.length > 0 ? normalized : undefined
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return undefined
|
||
}
|
||
return value as Record<string, JsonValue>
|
||
}
|
||
|
||
function asSourceItems(value: unknown): MonitoringSourceItem[] | undefined {
|
||
if (!Array.isArray(value)) {
|
||
return undefined
|
||
}
|
||
|
||
const items: MonitoringSourceItem[] = []
|
||
for (const entry of value) {
|
||
const record = asRecord(entry)
|
||
const url = asOptionalString(record?.url)
|
||
if (!record || !url) {
|
||
continue
|
||
}
|
||
|
||
const item: MonitoringSourceItem = { url }
|
||
const title = asOptionalString(record.title)
|
||
const siteName = asOptionalString(record.site_name)
|
||
const siteKey = asOptionalString(record.site_key)
|
||
const normalizedURL = asOptionalString(record.normalized_url)
|
||
const host = asOptionalString(record.host)
|
||
const registrableDomain = asOptionalString(record.registrable_domain)
|
||
const subdomain = asOptionalString(record.subdomain)
|
||
const suffix = asOptionalString(record.suffix)
|
||
const articleID = asOptionalNumber(record.article_id)
|
||
const publishRecordID = asOptionalNumber(record.publish_record_id)
|
||
const resolutionStatus = asOptionalString(record.resolution_status)
|
||
const resolutionConfidence = asOptionalString(record.resolution_confidence)
|
||
|
||
if (title !== undefined) {
|
||
item.title = title
|
||
}
|
||
if (siteName !== undefined) {
|
||
item.site_name = siteName
|
||
}
|
||
if (siteKey !== undefined) {
|
||
item.site_key = siteKey
|
||
}
|
||
if (normalizedURL !== undefined) {
|
||
item.normalized_url = normalizedURL
|
||
}
|
||
if (host !== undefined) {
|
||
item.host = host
|
||
}
|
||
if (registrableDomain !== undefined) {
|
||
item.registrable_domain = registrableDomain
|
||
}
|
||
if (subdomain !== undefined) {
|
||
item.subdomain = subdomain
|
||
}
|
||
if (suffix !== undefined) {
|
||
item.suffix = suffix
|
||
}
|
||
if (articleID !== undefined) {
|
||
item.article_id = articleID
|
||
}
|
||
if (publishRecordID !== undefined) {
|
||
item.publish_record_id = publishRecordID
|
||
}
|
||
if (resolutionStatus !== undefined) {
|
||
item.resolution_status = resolutionStatus
|
||
}
|
||
if (resolutionConfidence !== undefined) {
|
||
item.resolution_confidence = resolutionConfidence
|
||
}
|
||
|
||
items.push(item)
|
||
}
|
||
|
||
return items.length > 0 ? items : undefined
|
||
}
|
||
|
||
function toInterruptGeneration(value: unknown): number {
|
||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
|
||
}
|
||
|
||
function toStructuredError(error: unknown): Record<string, JsonValue> {
|
||
if (error instanceof ApiClientError) {
|
||
return {
|
||
code: error.code,
|
||
message: error.message,
|
||
detail: error.detail ?? null,
|
||
request_id: error.requestId ?? null,
|
||
status: error.status ?? null,
|
||
}
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
return {
|
||
code: 'desktop_runtime_error',
|
||
message: error.message,
|
||
name: error.name,
|
||
}
|
||
}
|
||
|
||
return {
|
||
code: 'desktop_runtime_error',
|
||
message: String(error),
|
||
}
|
||
}
|
||
|
||
function errorMessage(error: unknown): string {
|
||
if (error instanceof ApiClientError) {
|
||
return error.detail ? `${error.message}: ${error.detail}` : error.message
|
||
}
|
||
if (error instanceof Error) {
|
||
return error.message
|
||
}
|
||
return String(error)
|
||
}
|
||
|
||
function parseTimestamp(value: string | null | undefined): number | null {
|
||
if (!value) {
|
||
return null
|
||
}
|
||
|
||
const timestamp = Date.parse(value)
|
||
return Number.isNaN(timestamp) ? null : timestamp
|
||
}
|
||
|
||
function isDesktopTaskEvent(value: unknown): value is DesktopTaskEventMessage {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return false
|
||
}
|
||
|
||
const event = value as Partial<DesktopTaskEventMessage>
|
||
return Boolean(
|
||
typeof event.type === 'string' &&
|
||
event.type !== 'task_control' &&
|
||
typeof event.task_id === 'string' &&
|
||
event.task_id.trim() !== '' &&
|
||
typeof event.target_client_id === 'string' &&
|
||
event.target_client_id.trim() !== '' &&
|
||
(event.kind === 'publish' || event.kind === 'monitor') &&
|
||
typeof event.status === 'string' &&
|
||
typeof event.updated_at === 'string',
|
||
)
|
||
}
|
||
|
||
function isMonitoringDispatchSignal(value: unknown): value is DesktopTaskEventMessage {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return false
|
||
}
|
||
|
||
const event = value as Partial<DesktopTaskEventMessage>
|
||
return (
|
||
event.kind === 'monitor' &&
|
||
event.type === 'task_available' &&
|
||
event.signal_only === true &&
|
||
typeof event.target_client_id === 'string' &&
|
||
event.target_client_id.trim() !== ''
|
||
)
|
||
}
|
||
|
||
function isMonitoringTaskControlEvent(value: unknown): value is DesktopTaskEventMessage {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return false
|
||
}
|
||
|
||
const event = value as Partial<DesktopTaskEventMessage>
|
||
return (
|
||
event.kind === 'monitor' &&
|
||
event.type === 'task_control' &&
|
||
event.control === 'interrupt_requested' &&
|
||
typeof event.task_id === 'string' &&
|
||
event.task_id.trim() !== '' &&
|
||
typeof event.target_client_id === 'string' &&
|
||
event.target_client_id.trim() !== ''
|
||
)
|
||
}
|
||
|
||
function isConnectedEvent(value: unknown): value is { server_time: string } {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return false
|
||
}
|
||
return typeof (value as { server_time?: string }).server_time === 'string'
|
||
}
|
||
|
||
function isReconnectPayload(value: unknown): value is { delayMs: number } {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return false
|
||
}
|
||
return typeof (value as { delayMs?: number }).delayMs === 'number'
|
||
}
|