feat(doubao): implement challenge handling and monitoring improvements
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 3m12s
Backend CI / Backend (push) Failing after 10m8s
Desktop Client Build / Build Desktop Client (push) Successful in 23m53s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 3m12s
Backend CI / Backend (push) Failing after 10m8s
Desktop Client Build / Build Desktop Client (push) Successful in 23m53s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s
- Added classification for `doubao_challenge_required` as a challenge in platform-auth-adapters. - Enhanced account action summaries to include specific messages for Doubao human verification. - Introduced monitoring functions to filter out blocked platforms based on account health. - Updated task leasing to support platform-specific filtering for monitor tasks. - Refined issue aggregation in HomeView to consolidate Doubao challenge failures into a single actionable item. - Improved backend logic to handle platform IDs in task leasing requests and responses. - Added tests for new functionality, ensuring proper handling of Doubao challenges and task leasing logic.
This commit is contained in:
@@ -3,15 +3,21 @@ import { describe, expect, it } from 'vitest'
|
||||
import { __doubaoTestUtils } from './doubao'
|
||||
|
||||
const {
|
||||
buildDoubaoDomSearchResults,
|
||||
buildSourceItem,
|
||||
dedupeSourceItems,
|
||||
doubaoModeProviderModel,
|
||||
doubaoModeKindFromLabelText,
|
||||
extractQuestionText,
|
||||
isDoubaoRecoverablePageError,
|
||||
isNonCitationAssetDomain,
|
||||
isNonCitationAssetUrl,
|
||||
hasDoubaoFinishedStreamCapture,
|
||||
parseDoubaoCaptures,
|
||||
parseDoubaoStreamBody,
|
||||
resolveDoubaoSubmissionAnswer,
|
||||
shouldAllowDoubaoDomAnswer,
|
||||
shouldAllowDoubaoReasoningAnswer,
|
||||
shouldRetryDoubaoPageAttempt,
|
||||
} = __doubaoTestUtils
|
||||
|
||||
@@ -92,6 +98,36 @@ describe('doubao adapter helpers', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not retry a recoverable page timeout when the page already has a usable answer', () => {
|
||||
const streamSummary = parseDoubaoStreamBody(
|
||||
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
|
||||
)
|
||||
|
||||
expect(
|
||||
shouldRetryDoubaoPageAttempt(
|
||||
{
|
||||
ok: false,
|
||||
error: 'doubao_query_timeout',
|
||||
detail: '页面轮询超时',
|
||||
url: 'https://www.doubao.com/chat/local_4995117315817271',
|
||||
title: '豆包',
|
||||
modelLabel: '豆包',
|
||||
modeLabel: '思考',
|
||||
thinkingModeApplied: true,
|
||||
deepThinkEnabled: true,
|
||||
domAnswer:
|
||||
'室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。',
|
||||
domReasoning: null,
|
||||
domLinks: [],
|
||||
submitted: true,
|
||||
questionAnchorFound: true,
|
||||
historySurfaceDetected: true,
|
||||
},
|
||||
streamSummary,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the new expert/deep-thinking mode label to expert mode', () => {
|
||||
expect(doubaoModeKindFromLabelText('专家')).toBe('expert')
|
||||
expect(doubaoModeKindFromLabelText('专家 深度思考 研究级智能模型')).toBe('expert')
|
||||
@@ -99,6 +135,11 @@ describe('doubao adapter helpers', () => {
|
||||
expect(doubaoModeKindFromLabelText('快速')).toBe('fast')
|
||||
})
|
||||
|
||||
it('reports Doubao default/expert mode as the fast web provider model', () => {
|
||||
expect(doubaoModeProviderModel('fast')).toBe('doubao-web-fast')
|
||||
expect(doubaoModeProviderModel('expert')).toBe('doubao-web-fast')
|
||||
})
|
||||
|
||||
it('filters byteimg and bytednsdoc asset CDN links from source items', () => {
|
||||
expect(isNonCitationAssetDomain('p3-spider-image-sign.byteimg.com')).toBe(true)
|
||||
expect(isNonCitationAssetDomain('lf3-static.bytednsdoc.com')).toBe(true)
|
||||
@@ -148,6 +189,103 @@ describe('doubao adapter helpers', () => {
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('merges multiple thinking search-reference groups from the page fallback', () => {
|
||||
const domLinks = [
|
||||
{
|
||||
url: 'https://example.com/guide-1',
|
||||
title: '2026年全国室内分体锁批发选购指南参考',
|
||||
text: '2026年全国室内分体锁批发选购指南参考',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/zhongshan-lock',
|
||||
title: '中山小榄头部锁厂盘点',
|
||||
text: '中山小榄头部锁厂盘点',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/brand-rank',
|
||||
title: '2026室内门锁品牌实测排行',
|
||||
text: '2026室内门锁品牌实测排行',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/guide-1#search-two',
|
||||
title: '重复链接应按去 hash 后合并',
|
||||
text: '重复链接应按去 hash 后合并',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: 'https://p11-spider-image-sign.byteimg.com/logo.jpeg',
|
||||
title: 'favicon asset',
|
||||
text: 'favicon asset',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: 'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html',
|
||||
title: 'Manufacturers of door locks supplier.html',
|
||||
text: 'Manufacturers of door locks supplier.html',
|
||||
siteName: null,
|
||||
},
|
||||
]
|
||||
|
||||
const results = buildDoubaoDomSearchResults(domLinks)
|
||||
|
||||
expect(results.map((item) => item.url)).toEqual([
|
||||
'https://example.com/guide-1',
|
||||
'https://example.com/zhongshan-lock',
|
||||
'https://example.com/brand-rank',
|
||||
'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html',
|
||||
])
|
||||
expect(results[0]?.title).toBe('2026年全国室内分体锁批发选购指南参考')
|
||||
})
|
||||
|
||||
it('keeps expanded Doubao reference links from the DOM fallback', () => {
|
||||
const results = buildDoubaoDomSearchResults([
|
||||
{
|
||||
url: 'https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%23card',
|
||||
title: 'real blue source title',
|
||||
text: 'real blue source title',
|
||||
siteName: 'example site',
|
||||
},
|
||||
{
|
||||
url: 'https://another.example.cn/article?from=doubao',
|
||||
title: 'another expanded source',
|
||||
text: 'another expanded source',
|
||||
siteName: null,
|
||||
},
|
||||
])
|
||||
|
||||
expect(results.map((item) => item.url)).toEqual([
|
||||
'https://example.com/source',
|
||||
'https://another.example.cn/article?from=doubao',
|
||||
])
|
||||
expect(results[0]).toMatchObject({
|
||||
title: 'real blue source title',
|
||||
site_name: 'example site',
|
||||
host: 'example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not turn Doubao gray search-query text into DOM search results', () => {
|
||||
const results = buildDoubaoDomSearchResults([
|
||||
{
|
||||
url: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"\nhttps://example.com/gray-text-only',
|
||||
title: '搜索 3 个关键词,参考 18 篇资料',
|
||||
text: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"',
|
||||
siteName: null,
|
||||
},
|
||||
{
|
||||
url: '1. reference https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dgray',
|
||||
title: 'gray explanation line',
|
||||
text: 'gray explanation line',
|
||||
siteName: null,
|
||||
},
|
||||
])
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('uses thinking-chain reference metadata to fill unknown Doubao source titles', () => {
|
||||
const body = [
|
||||
'event: STREAM_CHUNK',
|
||||
@@ -178,7 +316,7 @@ describe('doubao adapter helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts source URLs embedded in Doubao thinking-panel text', () => {
|
||||
it('still extracts embedded source URLs from structured SSE source payloads', () => {
|
||||
const item = buildSourceItem({
|
||||
url: '1. 合肥全屋定制榜单 https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dthink#card',
|
||||
title: '思考链路里的来源',
|
||||
@@ -330,6 +468,172 @@ describe('doubao adapter helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses in-page incremental SSE captures before Playwright response.body resolves', () => {
|
||||
const streamBody = [
|
||||
'event: SSE_ACK',
|
||||
'data: {"ack_client_meta":{"conversation_id":"38432170130380802"}}',
|
||||
'',
|
||||
'event: STREAM_CHUNK',
|
||||
'data: {"references":[{"url":"https://example.com/source-a","title":"真实参考来源","site_name":"示例站点"}]}',
|
||||
'',
|
||||
'event: CHUNK_DELTA',
|
||||
'data: {"text":"豆包 SSE 可以边生成边拿到答案,"}',
|
||||
'',
|
||||
'event: CHUNK_DELTA',
|
||||
'data: {"text":"即使底层连接还没有完全关闭,也应优先提交这段完整正文。"}',
|
||||
'',
|
||||
'event: SSE_REPLY_END',
|
||||
'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}',
|
||||
'',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const captures = [
|
||||
{
|
||||
captureId: 'fetch-1-1',
|
||||
url: 'https://www.doubao.com/chat/completion?aid=497858',
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: streamBody,
|
||||
},
|
||||
]
|
||||
const summary = parseDoubaoCaptures(captures)
|
||||
|
||||
expect(hasDoubaoFinishedStreamCapture(captures)).toBe(true)
|
||||
expect(summary.answer).toBe(
|
||||
'豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。',
|
||||
)
|
||||
expect(summary.answer_finish_event_count).toBe(1)
|
||||
expect(summary.search_results).toHaveLength(1)
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
answer: summary.answer,
|
||||
sseFinished: summary.answer_finish_event_count > 0,
|
||||
}),
|
||||
).toEqual({
|
||||
answer:
|
||||
'豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。',
|
||||
source: 'sse',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the page answer when SSE has not finished but the page is usable', () => {
|
||||
const domAnswer =
|
||||
'室内分体锁批发通常要先确认锁体中心距、面板材质、执手方向、钥匙系统和包装要求。批量采购时建议同时核验样品、交期、质检记录和售后换货规则。'
|
||||
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
answer: '室内分体锁批发',
|
||||
domAnswer,
|
||||
allowDomAnswer: true,
|
||||
sseFinished: false,
|
||||
}),
|
||||
).toEqual({
|
||||
answer: domAnswer,
|
||||
source: 'dom',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the finished SSE answer ahead of the page fallback', () => {
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
answer:
|
||||
'室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。',
|
||||
domAnswer: '页面上的室内分体锁批发答案通常更长,但完整 SSE 已经确认时仍优先使用 SSE 正文。',
|
||||
allowDomAnswer: true,
|
||||
sseFinished: true,
|
||||
}),
|
||||
).toEqual({
|
||||
answer:
|
||||
'室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。',
|
||||
source: 'sse',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows DOM fallback for recoverable Doubao page errors with an anchored answer', () => {
|
||||
const streamSummary = parseDoubaoStreamBody(
|
||||
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
|
||||
)
|
||||
|
||||
expect(
|
||||
shouldAllowDoubaoDomAnswer({
|
||||
streamSummary,
|
||||
pageResult: {
|
||||
ok: false,
|
||||
error: 'doubao_query_timeout',
|
||||
detail: '页面轮询超时',
|
||||
url: 'https://www.doubao.com/chat/local_4995117315817271',
|
||||
title: '豆包',
|
||||
modelLabel: '豆包',
|
||||
modeLabel: '思考',
|
||||
thinkingModeApplied: true,
|
||||
deepThinkEnabled: true,
|
||||
domAnswer:
|
||||
'室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。',
|
||||
domReasoning: null,
|
||||
domLinks: [],
|
||||
submitted: true,
|
||||
questionAnchorFound: true,
|
||||
historySurfaceDetected: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses thinking-chain text as the fallback answer when page answer is missing', () => {
|
||||
const reasoning =
|
||||
'我计划找室内门锁供货商,先搜索国内知名品牌厂家、批发采购渠道和区域供应商信息。已经整理出主流品牌供货商、源头工厂、线上批发平台及补充采购建议,可为用户提供全面参考。'
|
||||
const streamSummary = parseDoubaoStreamBody(
|
||||
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
|
||||
)
|
||||
const pageResult = {
|
||||
ok: false,
|
||||
error: 'doubao_query_timeout',
|
||||
detail: '页面轮询超时',
|
||||
url: 'https://www.doubao.com/chat/local_4995117315817271',
|
||||
title: '豆包',
|
||||
modelLabel: '豆包',
|
||||
modeLabel: '思考',
|
||||
thinkingModeApplied: true,
|
||||
deepThinkEnabled: true,
|
||||
domAnswer: null,
|
||||
domReasoning: reasoning,
|
||||
domLinks: [
|
||||
{
|
||||
url: 'https://example.com/source',
|
||||
title: '室内门锁供货商参考',
|
||||
text: '室内门锁供货商参考',
|
||||
siteName: null,
|
||||
},
|
||||
],
|
||||
submitted: true,
|
||||
questionAnchorFound: true,
|
||||
historySurfaceDetected: false,
|
||||
}
|
||||
|
||||
expect(
|
||||
shouldAllowDoubaoReasoningAnswer({
|
||||
pageResult,
|
||||
streamSummary,
|
||||
reasoning,
|
||||
searchResultCount: 1,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
answer: null,
|
||||
domAnswer: null,
|
||||
reasoning,
|
||||
allowDomAnswer: false,
|
||||
allowReasoningAnswer: true,
|
||||
sseFinished: false,
|
||||
}),
|
||||
).toEqual({
|
||||
answer: reasoning,
|
||||
source: 'reasoning',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not submit DOM answer before a finish signal allows DOM recovery', () => {
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
@@ -357,4 +661,25 @@ describe('doubao adapter helpers', () => {
|
||||
source: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not submit search-reference panels as the DOM answer', () => {
|
||||
expect(
|
||||
resolveDoubaoSubmissionAnswer({
|
||||
answer: null,
|
||||
domAnswer: [
|
||||
'已完成思考,参考 18 篇资料',
|
||||
'搜索 3 个关键词,参考 18 篇资料',
|
||||
'1. 2026 年五金配件采购指南',
|
||||
'2. 幽灵门轨道安装注意事项',
|
||||
'3. https://example.com/door-hardware',
|
||||
'4. 某某官网|产品资料',
|
||||
'5. 行业资讯 - 采购参考',
|
||||
].join('\n'),
|
||||
allowDomAnswer: true,
|
||||
}),
|
||||
).toEqual({
|
||||
answer: null,
|
||||
source: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,11 @@ function classifyFailure(input: PlatformFailureInput): PlatformFailureClassifica
|
||||
}
|
||||
|
||||
function classifyDoubaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
|
||||
const code = typeof input.error?.code === 'string' ? input.error.code : ''
|
||||
if (code === 'doubao_challenge_required') {
|
||||
return 'challenge'
|
||||
}
|
||||
|
||||
const text = normalizeFailureText(input)
|
||||
if (!text) {
|
||||
return 'ok'
|
||||
|
||||
@@ -454,9 +454,52 @@ function accountActionRequiredSummary(
|
||||
if (authReason === 'risk_control') {
|
||||
return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。`
|
||||
}
|
||||
if (platform === 'doubao') {
|
||||
return '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。'
|
||||
}
|
||||
return `${label} 账号需要完成人机验证后才能继续执行。`
|
||||
}
|
||||
|
||||
function monitorPlatformBlockedReason(platform: string): string | null {
|
||||
const account = state.accounts.find(
|
||||
(item) => item.platform === platform && isAccountOwnedByCurrentClient(item),
|
||||
)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
})
|
||||
if (projected.authState === 'challenge_required') {
|
||||
return accountActionRequiredSummary(account.platform, projected.authReason)
|
||||
}
|
||||
if (projected.authState === 'expired' || projected.authState === 'revoked') {
|
||||
return `${runtimePlatformLabel(account.platform)} 账号登录态已失效,请重新授权后再继续执行。`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function blockedMonitorPlatforms(): Set<string> {
|
||||
const blocked = new Set<string>()
|
||||
for (const platform of aiPlatformCatalog.map((item) => item.id)) {
|
||||
if (monitorPlatformBlockedReason(platform)) {
|
||||
blocked.add(platform)
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
function eligibleMonitorPlatformIds(): string[] {
|
||||
const blocked = blockedMonitorPlatforms()
|
||||
return aiPlatformCatalog
|
||||
.map((platform) => platform.id)
|
||||
.filter((platform) => !blocked.has(platform))
|
||||
}
|
||||
|
||||
function markAuthorizationFailureNonRetryable(
|
||||
error: Record<string, JsonValue>,
|
||||
category: 'ai_platform_authorization' | 'media_account_authorization',
|
||||
@@ -620,6 +663,14 @@ function maybeRecordAccountAccessAlert(
|
||||
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
||||
return
|
||||
}
|
||||
if (accountIdentity.platform === 'doubao') {
|
||||
recordActivity(
|
||||
'warn',
|
||||
'豆包需要人工验证',
|
||||
`${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`,
|
||||
)
|
||||
return
|
||||
}
|
||||
recordActivity(
|
||||
'warn',
|
||||
`${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`,
|
||||
@@ -1938,11 +1989,15 @@ function pumpExecutionLoop(): void {
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
const blockedMonitorExecutionPlatforms = new Set([
|
||||
...blockedPlaywrightPlatforms,
|
||||
...blockedMonitorPlatforms(),
|
||||
])
|
||||
const selected = selectNextMonitorTask({
|
||||
maxConcurrency: resolveAdaptiveMonitorConcurrency(),
|
||||
activePlatforms: activeMonitorPlatforms(),
|
||||
activeQuestionKeys: activeMonitorQuestionKeys(),
|
||||
blockedPlatforms: blockedPlaywrightPlatforms,
|
||||
blockedPlatforms: blockedMonitorExecutionPlatforms,
|
||||
activeCount: activeMonitorCount(),
|
||||
})
|
||||
if (selected) {
|
||||
@@ -2041,7 +2096,15 @@ async function pullNextTask(kind: 'publish' | 'monitor'): Promise<void> {
|
||||
|
||||
state.leaseInFlightKinds.add('monitor')
|
||||
try {
|
||||
const leased = await leaseDesktopTask({ kind: 'monitor' })
|
||||
const eligiblePlatformIds = eligibleMonitorPlatformIds()
|
||||
if (eligiblePlatformIds.length === 0) {
|
||||
state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs
|
||||
return
|
||||
}
|
||||
const leased = await leaseDesktopTask({
|
||||
kind: 'monitor',
|
||||
platform_ids: eligiblePlatformIds,
|
||||
})
|
||||
state.lastPullAt = Date.now()
|
||||
state.lastPullStatus = 'success'
|
||||
noteTransportPull(true)
|
||||
@@ -2125,8 +2188,14 @@ async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise<void> {
|
||||
state.leaseInFlightKinds.add('monitor')
|
||||
|
||||
try {
|
||||
const eligiblePlatformIds = eligibleMonitorPlatformIds()
|
||||
if (eligiblePlatformIds.length === 0) {
|
||||
state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs
|
||||
return
|
||||
}
|
||||
const leased = await leaseMonitoringTasks({
|
||||
limit: leaseLimit,
|
||||
ai_platform_ids: eligiblePlatformIds,
|
||||
})
|
||||
state.lastPullAt = Date.now()
|
||||
state.lastPullStatus = 'success'
|
||||
@@ -2156,7 +2225,13 @@ async function resumeLeasedMonitoringTasks(source: 'startup' | 'reconnect'): Pro
|
||||
}
|
||||
|
||||
try {
|
||||
const resumed = await resumeMonitoringTasks({})
|
||||
const eligiblePlatformIds = eligibleMonitorPlatformIds()
|
||||
if (eligiblePlatformIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const resumed = await resumeMonitoringTasks({
|
||||
ai_platform_ids: eligiblePlatformIds,
|
||||
})
|
||||
enqueueLeasedMonitoringTasks(resumed.tasks, 'db-recovery')
|
||||
if (resumed.tasks.length > 0) {
|
||||
recordActivity(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDesktopRuntime } from '../composables/useDesktopRuntime'
|
||||
import { formatRelativeTime } from '../lib/formatters'
|
||||
import { translateDesktopPlatform } from '../lib/media-catalog'
|
||||
import { healthTone } from '../lib/runtime-ui'
|
||||
import type { RuntimeTask } from '../types'
|
||||
|
||||
function translatePlatform(platform: string) {
|
||||
return translateDesktopPlatform(platform)
|
||||
@@ -55,6 +56,40 @@ type RuntimeIssueItem = {
|
||||
at: number
|
||||
}
|
||||
|
||||
const DOUBAO_CHALLENGE_DETAIL =
|
||||
'豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。'
|
||||
const DOUBAO_CHALLENGE_PATTERN =
|
||||
/doubao_challenge_required|challenge_required|captcha_gate|risk_control|人机验证|人工验证|验证码|风控|安全验证/i
|
||||
|
||||
function isDoubaoPlatform(platform: string) {
|
||||
return platform.toLowerCase() === 'doubao'
|
||||
}
|
||||
|
||||
function doubaoTaskChallengeKey(task: RuntimeTask) {
|
||||
return `${task.platform.toLowerCase()}::${task.accountId || task.accountName || 'unknown'}`
|
||||
}
|
||||
|
||||
function doubaoAccountChallengeKey(account: {
|
||||
platform: string
|
||||
id: string
|
||||
displayName: string
|
||||
platformUid: string
|
||||
}) {
|
||||
return `${account.platform.toLowerCase()}::${account.id || account.displayName || account.platformUid || 'unknown'}`
|
||||
}
|
||||
|
||||
function isDoubaoChallengeTask(task: RuntimeTask) {
|
||||
if (!isDoubaoPlatform(task.platform)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DOUBAO_CHALLENGE_PATTERN.test(`${task.summary || ''} ${task.title || ''}`)
|
||||
}
|
||||
|
||||
function isDoubaoChallengeAccount(account: { platform: string; authState: string }) {
|
||||
return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required'
|
||||
}
|
||||
|
||||
function translateEventTitle(title: string) {
|
||||
const map: Record<string, string> = {
|
||||
'Accounts Synced': '本地账号同步',
|
||||
@@ -115,37 +150,110 @@ const blockedAccounts = computed(() =>
|
||||
const blockedTasks = computed(() =>
|
||||
tasks.value.filter((item) => ['failed', 'unknown'].includes(item.status)),
|
||||
)
|
||||
const issueBreakdown = computed(() => ({
|
||||
tasks: blockedTasks.value.length,
|
||||
accounts: blockedAccounts.value.length,
|
||||
total: blockedTasks.value.length + blockedAccounts.value.length,
|
||||
}))
|
||||
const issueItems = computed<RuntimeIssueItem[]>(() => {
|
||||
const taskIssues = blockedTasks.value.map((task) => ({
|
||||
id: `task-${task.id}`,
|
||||
kind: 'task' as const,
|
||||
title: task.title || task.jobId,
|
||||
meta: `${task.accountName} · ${translatePlatform(task.platform)}`,
|
||||
detail: task.summary,
|
||||
badgeLabel: translateTaskStatus(task.status),
|
||||
badgeTone: 'danger' as const,
|
||||
at: task.updatedAt,
|
||||
}))
|
||||
const accountIssues = blockedAccounts.value.map((account) => ({
|
||||
id: `account-${account.id}`,
|
||||
kind: 'account' as const,
|
||||
title: account.displayName,
|
||||
meta: `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`,
|
||||
detail:
|
||||
account.authReason === 'risk_control'
|
||||
? '平台触发风控,需要人工验证后恢复任务执行。'
|
||||
: '授权状态不可用,需要重新登录或确认账号状态。',
|
||||
badgeLabel: blockedAccountLabel(account),
|
||||
badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const),
|
||||
at: account.lastVerifiedAt ?? account.lastSyncAt,
|
||||
}))
|
||||
const doubaoChallengeTaskGroups = new Map<string, RuntimeTask[]>()
|
||||
|
||||
return [...taskIssues, ...accountIssues].sort((left, right) => right.at - left.at)
|
||||
for (const task of blockedTasks.value) {
|
||||
if (!isDoubaoChallengeTask(task)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = doubaoTaskChallengeKey(task)
|
||||
const group = doubaoChallengeTaskGroups.get(key)
|
||||
if (group) {
|
||||
group.push(task)
|
||||
} else {
|
||||
doubaoChallengeTaskGroups.set(key, [task])
|
||||
}
|
||||
}
|
||||
|
||||
const doubaoChallengeAccountKeys = new Set(
|
||||
blockedAccounts.value
|
||||
.filter(isDoubaoChallengeAccount)
|
||||
.map((account) => doubaoAccountChallengeKey(account)),
|
||||
)
|
||||
const doubaoChallengeKeys = new Set([
|
||||
...doubaoChallengeTaskGroups.keys(),
|
||||
...doubaoChallengeAccountKeys,
|
||||
])
|
||||
|
||||
const taskIssues = blockedTasks.value
|
||||
.filter(
|
||||
(task) =>
|
||||
!isDoubaoPlatform(task.platform) || !doubaoChallengeKeys.has(doubaoTaskChallengeKey(task)),
|
||||
)
|
||||
.map((task) => ({
|
||||
id: `task-${task.id}`,
|
||||
kind: 'task' as const,
|
||||
title: task.title || task.jobId,
|
||||
meta: `${task.accountName} · ${translatePlatform(task.platform)}`,
|
||||
detail: task.summary,
|
||||
badgeLabel: translateTaskStatus(task.status),
|
||||
badgeTone: 'danger' as const,
|
||||
at: task.updatedAt,
|
||||
}))
|
||||
|
||||
const doubaoChallengeTaskIssues = Array.from(doubaoChallengeTaskGroups.entries())
|
||||
.filter(([key]) => !doubaoChallengeAccountKeys.has(key))
|
||||
.map(([key, challengeTasks]) => {
|
||||
const mergedTasks = blockedTasks.value.filter(
|
||||
(task) => isDoubaoPlatform(task.platform) && doubaoTaskChallengeKey(task) === key,
|
||||
)
|
||||
const representative = mergedTasks.reduce(
|
||||
(latest, task) => (task.updatedAt > latest.updatedAt ? task : latest),
|
||||
challengeTasks[0],
|
||||
)
|
||||
const metaParts = [
|
||||
`${representative.accountName || '豆包账号'} · ${translatePlatform(representative.platform)}`,
|
||||
mergedTasks.length > 1 ? `已合并 ${mergedTasks.length} 个失败任务` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
id: `task-doubao-challenge-${key}`,
|
||||
kind: 'task' as const,
|
||||
title: '豆包需要人工验证',
|
||||
meta: metaParts.join(' · '),
|
||||
detail: DOUBAO_CHALLENGE_DETAIL,
|
||||
badgeLabel: '执行失败',
|
||||
badgeTone: 'danger' as const,
|
||||
at: Math.max(...mergedTasks.map((task) => task.updatedAt)),
|
||||
}
|
||||
})
|
||||
|
||||
const accountIssues = blockedAccounts.value.map((account) => {
|
||||
const doubaoChallenge = isDoubaoChallengeAccount(account)
|
||||
|
||||
return {
|
||||
id: `account-${account.id}`,
|
||||
kind: 'account' as const,
|
||||
title: doubaoChallenge ? '豆包需要人工验证' : account.displayName,
|
||||
meta: doubaoChallenge
|
||||
? `${account.displayName || '豆包账号'} · ${translatePlatform(account.platform)}`
|
||||
: `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`,
|
||||
detail: doubaoChallenge
|
||||
? DOUBAO_CHALLENGE_DETAIL
|
||||
: account.authReason === 'risk_control'
|
||||
? '平台触发风控,需要人工验证后恢复任务执行。'
|
||||
: '授权状态不可用,需要重新登录或确认账号状态。',
|
||||
badgeLabel: doubaoChallenge ? '执行失败' : blockedAccountLabel(account),
|
||||
badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const),
|
||||
at: account.lastVerifiedAt ?? account.lastSyncAt,
|
||||
}
|
||||
})
|
||||
|
||||
return [...taskIssues, ...doubaoChallengeTaskIssues, ...accountIssues].sort(
|
||||
(left, right) => right.at - left.at,
|
||||
)
|
||||
})
|
||||
const issueBreakdown = computed(() => {
|
||||
const tasks = issueItems.value.filter((item) => item.kind === 'task').length
|
||||
const accounts = issueItems.value.filter((item) => item.kind === 'account').length
|
||||
|
||||
return {
|
||||
tasks,
|
||||
accounts,
|
||||
total: tasks + accounts,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -191,7 +299,7 @@ const issueItems = computed<RuntimeIssueItem[]>(() => {
|
||||
<span class="eyebrow">异常干预</span>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<strong>{{ snapshot?.summary.issuesOpen ?? 0 }}</strong>
|
||||
<strong>{{ issueBreakdown.total }}</strong>
|
||||
<p>任务异常 {{ issueBreakdown.tasks }} / 账号阻断 {{ issueBreakdown.accounts }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -344,6 +344,7 @@ export type TenantPublishTaskListResponse = DesktopPublishTaskListResponse
|
||||
export interface LeaseDesktopTaskRequest {
|
||||
task_id?: string
|
||||
kind?: 'publish' | 'monitor'
|
||||
platform_ids?: string[]
|
||||
}
|
||||
|
||||
export interface LeaseDesktopTaskResponse {
|
||||
|
||||
+67
@@ -826,3 +826,70 @@
|
||||
- Searched `apps/desktop-client/out/main/bootstrap.cjs` for `wO`, `_0x...`, `stringArray`, `javascript-obfuscator`, and `['evaluate']`; no obfuscator helper traces were present after `build:obf`.
|
||||
- Scoped implementation to tenant publish task list/retry endpoints plus a new admin-web `/publish-management` page under 内容管理.
|
||||
- Noted existing dirty files in i18n/Knowledge/TemplateWizard/config/swagger/knowledge service and will avoid reverting or overwriting unrelated changes.
|
||||
|
||||
## 2026-06-23T23:54:39+08:00 - Doubao thinking-reference fallback
|
||||
|
||||
- Investigated the Doubao monitor failure where the answer page showed "已完成思考 / 搜索 N 个关键词,参考 N 篇资料" links but the adapter returned `unknown`.
|
||||
- Current Doubao behavior observed from user screenshots:
|
||||
- citation/source links now live inside the thinking block rather than next to the final answer;
|
||||
- one answer can contain multiple "搜索 X 个关键词,参考 Y 篇资料" groups;
|
||||
- "已完成思考,参考 N 篇资料" is a broad thinking summary control, while the search groups hold the concrete blue source links;
|
||||
- already-expanded groups may still show a chevron, so clicking blindly can collapse useful references.
|
||||
- Updated `apps/desktop-client/src/main/adapters/doubao.ts` so `/chat/completion` and `text/event-stream` responses remain the preferred SSE source, while a high-quality page answer can be used when SSE has not finished.
|
||||
- Strengthened page fallback to expand/reference-scan Doubao's thinking search groups, skip already-expanded nearby-link groups, merge multiple thinking reference groups, and filter favicon/asset CDN links.
|
||||
- Added helper coverage in `doubao.test.ts` for unfinished SSE + usable page answer, finished SSE precedence, recoverable timeout with anchored page answer, and multi-group thinking references.
|
||||
- Follow-up fix: the `unknown` message "未从正文或思维链兜底抓到可提交答案,仅抓到参考资料" still happened because the implementation only used `domAnswer` as the fallback answer and did not submit `domReasoning`. The adapter now allows a high-quality thinking-chain text fallback when SSE/page answer text is missing but references and anchored page context were captured.
|
||||
- Local anonymous Playwright could open Doubao only to the logged-out/home page, so live account-specific MCP/page content was not directly readable in this session.
|
||||
- Verification passed:
|
||||
- `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts`
|
||||
- `pnpm --filter @geo/desktop-client typecheck`
|
||||
- `pnpm --filter @geo/desktop-client test`
|
||||
- `git diff --check`
|
||||
|
||||
## 2026-06-24T01:20:00+08:00 - Doubao SSE capture and challenge handling
|
||||
|
||||
- Confirmed from the latest user screenshot that Doubao can trigger an official-site image captcha after a prompt is submitted. This is an account/platform challenge, not an answer parsing failure.
|
||||
- Hardened `apps/desktop-client/src/main/adapters/doubao.ts`:
|
||||
- capture Doubao `/chat/completion` SSE in-page through fetch/XHR stream cloning and keep SSE as the primary answer/source path;
|
||||
- keep DOM/page extraction as fallback, including thinking/reference panels for `search_results` without promoting reference lists to `answer`;
|
||||
- verify composer write/readback before sending, avoid selecting voice/microphone controls as send buttons, and add a Playwright keyboard/mouse submit fallback when the page does not observe the user question;
|
||||
- detect Doubao captcha/risk-control surfaces such as image selection, drag-to-area, human verification, security verification, and frequent-access prompts;
|
||||
- return `doubao_challenge_required` as failed/non-retryable AI platform authorization when a captcha is visible, so the desktop UI alerts the user instead of writing `unknown`.
|
||||
- Updated `apps/desktop-client/src/main/platform-auth-adapters.ts` so `doubao_challenge_required` is classified as a challenge.
|
||||
- Updated `apps/desktop-client/src/main/runtime-controller.ts` so Doubao challenge states show a specific user-facing activity: complete Doubao human verification in the desktop client before continuing collection.
|
||||
- Verification passed:
|
||||
- `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts`
|
||||
- `pnpm --filter @geo/desktop-client typecheck`
|
||||
- `git diff --check`
|
||||
- `pnpm --filter @geo/desktop-client build`
|
||||
- Remote SSH to `39.105.229.239` with the provided credentials failed with authentication failure, so remote logs/deployment could not be inspected in this pass.
|
||||
|
||||
## 2026-06-24T02:22:00+08:00 - Doubao captcha display, admission, and default mode
|
||||
|
||||
- Updated desktop HomeView issue aggregation so Doubao human-verification/account challenge failures collapse into one actionable item: "豆包需要人工验证"; the summary count now follows displayed issue rows instead of raw failed task rows.
|
||||
- Added monitor admission filtering for blocked AI platforms:
|
||||
- desktop runtime computes an eligible monitor platform whitelist from current local account health;
|
||||
- Doubao `challenge_required`/expired/revoked states are excluded from local monitor selection, desktop-task monitor lease fallback, legacy monitoring lease fallback, and resume calls;
|
||||
- other AI platforms remain eligible, so a Doubao captcha no longer blocks Qwen/Kimi/DeepSeek/etc. work.
|
||||
- Extended `/api/desktop/tasks/lease` with optional `platform_ids` for monitor leases and filtered the server-side monitor candidate SQL by that whitelist.
|
||||
- Changed Doubao execution to use the default visible mode directly:
|
||||
- removed active in-page switching to "专家"/"思考" and removed deep-think selection parameters from Doubao query execution;
|
||||
- `provider_model` now reports `doubao-web-fast` for default/fast and for any observed expert label, with no `doubao-web-expert` remaining.
|
||||
- Verification passed:
|
||||
- `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts`
|
||||
- `pnpm --filter @geo/desktop-client typecheck`
|
||||
- `pnpm --filter @geo/desktop-client build`
|
||||
- `cd server && go test ./internal/tenant/app ./internal/tenant/transport`
|
||||
- `git diff --check`
|
||||
|
||||
## 2026-06-24T02:29:25+08:00 - Doubao DOM source strictness
|
||||
|
||||
- Refined Doubao citation extraction after the user clarified that captcha/risk-control pages may legitimately have no reference sources.
|
||||
- Kept SSE as the preferred and broad source path, including structured references that embed Doubao redirect URLs in source payloads.
|
||||
- Tightened DOM/page fallback so `search_results` only comes from real link/card URLs and redirect URL attributes; gray search-query/explanatory text such as "search N keywords, reference N sources" is no longer converted into citation sources.
|
||||
- Removed the page-side fallback that scraped arbitrary `http` URLs from element text, so expanded blue reference links are still captured while gray search keyword text is ignored.
|
||||
- Verification passed:
|
||||
- `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts`
|
||||
- `pnpm --filter @geo/desktop-client typecheck`
|
||||
- `pnpm --filter @geo/desktop-client build`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -103,8 +103,9 @@ type DesktopTaskView struct {
|
||||
}
|
||||
|
||||
type LeaseDesktopTaskRequest struct {
|
||||
TaskID *string `json:"task_id"`
|
||||
Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"`
|
||||
TaskID *string `json:"task_id"`
|
||||
Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"`
|
||||
PlatformIDs []string `json:"platform_ids"`
|
||||
}
|
||||
|
||||
type LeaseDesktopTaskResponse struct {
|
||||
@@ -213,6 +214,7 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
|
||||
AttemptID: attemptID,
|
||||
LeaseTokenHash: tokenHash,
|
||||
}
|
||||
eligiblePlatformIDs := normalizeDesktopTaskLeasePlatformIDs(req.PlatformIDs)
|
||||
|
||||
if s.logger != nil {
|
||||
fields := []zap.Field{
|
||||
@@ -232,7 +234,7 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
|
||||
case taskID != nil:
|
||||
task, err = s.leaseQueuedDesktopTaskByID(ctx, client, *taskID, leaseParams)
|
||||
case req.Kind != nil && strings.TrimSpace(*req.Kind) == "monitor":
|
||||
task, err = s.leaseNextQueuedMonitorTask(ctx, client, leaseParams)
|
||||
task, err = s.leaseNextQueuedMonitorTask(ctx, client, leaseParams, eligiblePlatformIDs)
|
||||
case req.Kind != nil && strings.TrimSpace(*req.Kind) == "publish":
|
||||
task, err = s.leaseNextQueuedPublishTask(ctx, client, leaseParams)
|
||||
default:
|
||||
@@ -519,6 +521,7 @@ func (s *DesktopTaskService) leaseNextQueuedMonitorTask(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
params repository.DesktopTaskLeaseParams,
|
||||
eligiblePlatformIDs []string,
|
||||
) (*repository.DesktopTask, error) {
|
||||
tx, err := beginDesktopMonitorLeaseTx(ctx, s.pool, client.ID)
|
||||
if err != nil {
|
||||
@@ -533,6 +536,7 @@ func (s *DesktopTaskService) leaseNextQueuedMonitorTask(
|
||||
params.AttemptID,
|
||||
params.LeaseTokenHash,
|
||||
maxConcurrentMonitorPlatformsPerDesktopClient,
|
||||
eligiblePlatformIDs,
|
||||
)
|
||||
task, err := scanRepositoryDesktopTask(row)
|
||||
if err != nil {
|
||||
@@ -577,6 +581,10 @@ func leaseNextQueuedMonitorTaskSQL() string {
|
||||
AND dt.platform_id = ca.platform_id
|
||||
AND dt.kind = 'monitor'
|
||||
AND dt.status = 'queued'
|
||||
AND (
|
||||
COALESCE(array_length($7::text[], 1), 0) = 0
|
||||
OR dt.platform_id = ANY($7::text[])
|
||||
)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM desktop_tasks AS active
|
||||
@@ -668,6 +676,30 @@ func leaseNextQueuedMonitorTaskSQL() string {
|
||||
RETURNING ` + desktopTaskRepositoryReturningColumns
|
||||
}
|
||||
|
||||
func normalizeDesktopTaskLeasePlatformIDs(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
platformID := strings.ToLower(strings.TrimSpace(value))
|
||||
if platformID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[platformID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[platformID] = struct{}{}
|
||||
result = append(result, platformID)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func monitorAccountLeaseLockSQL(alias string) string {
|
||||
return fmt.Sprintf(`hashtextextended(
|
||||
concat_ws(
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -339,6 +340,7 @@ func TestLeaseNextQueuedMonitorTaskSQLUsesAccountIdentityAndClientSlots(t *testi
|
||||
for _, fragment := range []string{
|
||||
"WITH current_accounts AS",
|
||||
"client_id = $3",
|
||||
"COALESCE(array_length($7::text[], 1), 0) = 0 OR dt.platform_id = ANY($7::text[])",
|
||||
"dt.target_account_id = target_account.desktop_id",
|
||||
"target_account.account_fingerprint",
|
||||
"target_account.platform_uid",
|
||||
@@ -366,6 +368,19 @@ func TestLeaseNextQueuedMonitorTaskSQLUsesAccountIdentityAndClientSlots(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopTaskLeasePlatformIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeDesktopTaskLeasePlatformIDs([]string{" Doubao ", "qwen", "", "DOUBAO"})
|
||||
want := []string{"doubao", "qwen"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalizeDesktopTaskLeasePlatformIDs() = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := normalizeDesktopTaskLeasePlatformIDs([]string{" ", ""}); got != nil {
|
||||
t.Fatalf("normalizeDesktopTaskLeasePlatformIDs(blank) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseQueuedDesktopTaskByIDSQLUsesAccountIdentityAndClientSlots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -420,6 +420,11 @@ func (s *MonitoringCallbackService) handleTaskResultForInstallation(
|
||||
}, nil
|
||||
}
|
||||
|
||||
if response := idempotentDesktopMonitoringResultResponse(task); response != nil {
|
||||
_ = s.touchInstallation(ctx, installation.ID)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if task.ExecutionOwner == "desktop_tasks" {
|
||||
if err := s.validateDesktopMonitorExecutionLease(ctx, task, installation.ID, req.LeaseToken); err != nil {
|
||||
return nil, err
|
||||
@@ -3538,6 +3543,24 @@ func validateMonitoringTaskResultSubmission(platformID string, runStatus string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func idempotentDesktopMonitoringResultResponse(task *monitoringCollectTask) *MonitoringTaskResultResponse {
|
||||
if task == nil || task.ExecutionOwner != "desktop_tasks" || !task.CallbackReceivedAt.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch task.Status {
|
||||
case "received", "failed", "skipped":
|
||||
return &MonitoringTaskResultResponse{
|
||||
TaskID: task.ID,
|
||||
TaskStatus: task.Status,
|
||||
CitationSourceCount: 0,
|
||||
ContentCitationCount: 0,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMonitoringLeaseLimit(value int) int {
|
||||
if value <= 0 {
|
||||
return defaultMonitoringLeaseLimit
|
||||
|
||||
@@ -384,6 +384,68 @@ func TestMonitoringTaskReadyForQueuedResultAcceptsDesktopReceivedStatus(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotentDesktopMonitoringResultResponseAcceptsReceivedTerminalStates(t *testing.T) {
|
||||
for _, status := range []string{"received", "failed", "skipped"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
got := idempotentDesktopMonitoringResultResponse(&monitoringCollectTask{
|
||||
ID: 123,
|
||||
ExecutionOwner: "desktop_tasks",
|
||||
Status: status,
|
||||
CallbackReceivedAt: sqlNullTimeForTest(),
|
||||
})
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("idempotentDesktopMonitoringResultResponse() = nil, want response")
|
||||
}
|
||||
if got.TaskID != 123 {
|
||||
t.Fatalf("TaskID = %d, want 123", got.TaskID)
|
||||
}
|
||||
if got.TaskStatus != status {
|
||||
t.Fatalf("TaskStatus = %q, want %q", got.TaskStatus, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotentDesktopMonitoringResultResponseRequiresDesktopCallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task monitoringCollectTask
|
||||
}{
|
||||
{
|
||||
name: "legacy received",
|
||||
task: monitoringCollectTask{
|
||||
ExecutionOwner: "legacy",
|
||||
Status: "received",
|
||||
CallbackReceivedAt: sqlNullTimeForTest(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop callback missing",
|
||||
task: monitoringCollectTask{
|
||||
ExecutionOwner: "desktop_tasks",
|
||||
Status: "received",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop pending",
|
||||
task: monitoringCollectTask{
|
||||
ExecutionOwner: "desktop_tasks",
|
||||
Status: "pending",
|
||||
CallbackReceivedAt: sqlNullTimeForTest(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := idempotentDesktopMonitoringResultResponse(&tt.task); got != nil {
|
||||
t.Fatalf("idempotentDesktopMonitoringResultResponse() = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sqlNullTimeForTest() sql.NullTime {
|
||||
return sql.NullTime{Time: time.Now(), Valid: true}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user