From 145f958663fd6ccf4ba40f210f99cd3d557a523c Mon Sep 17 00:00:00 2001 From: renzhiye Date: Thu, 2 Apr 2026 18:47:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=20v1=20=E4=B8=8E=E4=BB=BB=E5=8A=A1=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=87=86=E5=A4=87=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/server.test.ts | 202 +++++++++ apps/api/src/server.ts | 68 ++- apps/api/src/store.ts | 662 ++++++++++++++++++++++++++++-- apps/web/src/App.tsx | 205 +++++++-- apps/web/src/HistoryPage.test.tsx | 2 + apps/web/src/NewTaskPage.test.tsx | 158 +++++++ apps/web/src/styles.css | 68 ++- packages/domain/src/enums.ts | 36 ++ packages/domain/src/models.ts | 120 ++++++ 9 files changed, 1462 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/NewTaskPage.test.tsx diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index 258ed1d..7ae412b 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -49,6 +49,46 @@ describe("API server", () => { await app.close(); }); + it("exposes session state with a 24-hour expiry window after preparation", async () => { + const app = createServer(); + await app.ready(); + + const prepareResponse = await app.inject({ + method: "POST", + url: "/api/platforms/jd/prepare" + }); + + expect(prepareResponse.statusCode).toBe(200); + const preparedPayload = prepareResponse.json(); + expect(preparedPayload).toMatchObject({ + platform: "jd", + session_ready: true, + status: "ready", + encrypted_snapshot_available: true + }); + expect( + Date.parse(preparedPayload.expires_at) - Date.parse(preparedPayload.last_prepared_at) + ).toBe(24 * 60 * 60 * 1000); + + const sessionResponse = await app.inject({ + method: "GET", + url: "/api/sessions/jd" + }); + + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json().session).toMatchObject({ + platform: "jd", + ready: true, + status: "ready", + scope: "workspace", + ttlHours: 24, + encryptedSnapshotAvailable: true, + cipherLabel: "mock-aes-gcm-v1" + }); + + await app.close(); + }); + it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => { const app = createServer(); await app.ready(); @@ -82,6 +122,102 @@ describe("API server", () => { await app.close(); }); + it("records strategy attempts and observability metrics for platform search", async () => { + const app = createServer(); + await app.ready(); + + const createdTask = await createTask(app, "iPhone 15 Pro"); + + const attemptsResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/strategy-attempts` + }); + + expect(attemptsResponse.statusCode).toBe(200); + expect(attemptsResponse.json().attempts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + platform: "tmall", + capability: "search", + outcome: "succeeded", + layer: "L1" + }), + expect.objectContaining({ + platform: "jd", + capability: "search", + outcome: "blocked", + layer: "L0", + errorType: "session_required" + }) + ]) + ); + + const overviewResponse = await app.inject({ + method: "GET", + url: "/api/observability/overview" + }); + + expect(overviewResponse.statusCode).toBe(200); + expect(overviewResponse.json().overview.strategyAttempts).toMatchObject({ + total: 2, + searchSuccessRate: 50 + }); + expect( + overviewResponse + .json() + .overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd") + ).toMatchObject({ + platform: "jd", + searchDurationMs: expect.any(Number) + }); + + await app.close(); + }); + + it("clears a prepared session and updates the readiness summary", async () => { + const app = createServer(); + await app.ready(); + + await preparePlatform(app, "jd"); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: "/api/sessions/jd" + }); + + expect(deleteResponse.statusCode).toBe(204); + + const sessionResponse = await app.inject({ + method: "GET", + url: "/api/sessions/jd" + }); + + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json().session).toMatchObject({ + platform: "jd", + ready: false, + status: "missing", + encryptedSnapshotAvailable: false + }); + + const readinessResponse = await app.inject({ + method: "GET", + url: "/api/platforms/readiness" + }); + + expect( + readinessResponse + .json() + .platforms.find((platform: { platform: string }) => platform.platform === "jd") + ).toMatchObject({ + platform: "jd", + ready: false, + status: "missing" + }); + + await app.close(); + }); + it("supports NoSelection terminal state when user confirms nothing", async () => { const app = createServer(); await app.ready(); @@ -181,6 +317,62 @@ describe("API server", () => { await app.close(); }); + it("records recovery audit entries and retry metrics for recovered platforms", async () => { + const app = createServer(); + await app.ready(); + + const createdTask = await createTask(app, "iPhone 15 Pro"); + + await preparePlatform(app, "jd"); + const retryResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry` + }); + + expect(retryResponse.statusCode).toBe(200); + + const auditResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/audit` + }); + + expect(auditResponse.statusCode).toBe(200); + expect(auditResponse.json().audit).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "platform.retry_started", + platform: "jd" + }), + expect.objectContaining({ + action: "task.recovery_completed", + platform: "jd" + }), + expect.objectContaining({ + action: "platform.retry_completed", + platform: "jd" + }) + ]) + ); + + const overviewResponse = await app.inject({ + method: "GET", + url: "/api/observability/overview" + }); + + expect( + overviewResponse + .json() + .overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd") + ).toMatchObject({ + platform: "jd", + retryCount: 1, + recoveryCount: 1 + }); + expect(overviewResponse.json().overview.audits.recoveryActions).toBe(2); + + await app.close(); + }); + it("publishes a new report version when a blocked platform recovers successfully", async () => { const app = createServer(); await app.ready(); @@ -340,6 +532,16 @@ describe("API server", () => { .tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId) ).toBe(false); + const overviewResponse = await app.inject({ + method: "GET", + url: "/api/observability/overview" + }); + expect(overviewResponse.json().overview.retention).toMatchObject({ + taskDeletes: 1, + deletedReports: 1 + }); + expect(overviewResponse.json().overview.audits.deleteActions).toBe(1); + await app.close(); }); }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0aa501d..f3b06c3 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -23,18 +23,50 @@ export function createServer() { platforms: store.getPlatformReadiness() })); + app.get("/api/sessions", async () => ({ + sessions: store.listSessions() + })); + + app.get<{ + Params: { platform: PlatformId }; + }>("/api/sessions/:platform", async (request, reply) => { + try { + const session = store.getSession(request.params.platform); + return { session }; + } catch { + reply.code(404); + return { message: "Session not found." }; + } + }); + app.post<{ Params: { platform: PlatformId }; }>("/api/platforms/:platform/prepare", async (request, reply) => { - const readiness = store.preparePlatform(request.params.platform); + const session = store.preparePlatform(request.params.platform); reply.code(200); return { - platform: readiness.platform, - session_ready: readiness.ready, - last_prepared_at: readiness.lastPreparedAt + platform: session.platform, + session_ready: session.ready, + status: session.status, + last_prepared_at: session.lastPreparedAt, + expires_at: session.expiresAt, + encrypted_snapshot_available: session.encryptedSnapshotAvailable }; }); + app.delete<{ + Params: { platform: PlatformId }; + }>("/api/sessions/:platform", async (request, reply) => { + try { + store.clearPlatformSession(request.params.platform); + reply.code(204); + return null; + } catch { + reply.code(404); + return { message: "Session not found." }; + } + }); + app.post<{ Body: CreateTaskInput; }>("/api/tasks", async (request, reply) => { @@ -78,6 +110,30 @@ export function createServer() { return { candidates }; }); + app.get<{ + Params: { taskId: string }; + }>("/api/tasks/:taskId/strategy-attempts", async (request, reply) => { + const attempts = store.getTaskStrategyAttempts(request.params.taskId); + if (!attempts) { + reply.code(404); + return { message: "Task not found." }; + } + + return { attempts }; + }); + + app.get<{ + Params: { taskId: string }; + }>("/api/tasks/:taskId/audit", async (request, reply) => { + const audit = store.getTaskAuditLogs(request.params.taskId); + if (!audit) { + reply.code(404); + return { message: "Task not found." }; + } + + return { audit }; + }); + app.post<{ Params: { taskId: string }; Body: ConfirmTaskPayload; @@ -120,6 +176,10 @@ export function createServer() { tasks: store.listHistory() })); + app.get("/api/observability/overview", async () => ({ + overview: store.getObservabilityOverview() + })); + app.get<{ Params: { taskId: string }; }>("/api/tasks/:taskId/events", async (request, reply) => { diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index caaa985..729ab68 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -1,17 +1,32 @@ import { + auditActions, deriveTaskStatusFromConfirmedPlatforms, mapPlatformStatusToExecutionStatus, platformCatalog, platformCatalogMap, platforms, + strategyAttemptOutcomes, + strategyLayers, + type AuditAction, + type AuditLogRecord, type CandidateRecord, type ConfirmTaskPayload, type CreateTaskInput, type HistoryTaskRecord, + type ObservabilityOverview, type PlatformId, + type PlatformCapability, + type PlatformRunMetricRecord, type PlatformRunRecord, type PlatformStatus, + type ReportMetricRecord, + type RetentionMetricRecord, type SessionReadinessRecord, + type SessionStateRecord, + type StrategyAttemptOutcome, + type StrategyAttemptRecord, + type StrategyAttemptTrigger, + type StrategyLayer, type TaskEventRecord, type TaskRecord } from "@cross-ai/domain"; @@ -31,6 +46,135 @@ function createPlatformCandidatesRecord(): Record }; } +function createPlatformRunMetricsRecord( + taskId: string, + timestamp: string +): Record { + return { + tmall: { + taskId, + platform: "tmall", + searchDurationMs: 0, + detailDurationMs: 0, + reviewsDurationMs: 0, + retryCount: 0, + recoveryCount: 0, + lastUpdatedAt: timestamp + }, + jd: { + taskId, + platform: "jd", + searchDurationMs: 0, + detailDurationMs: 0, + reviewsDurationMs: 0, + retryCount: 0, + recoveryCount: 0, + lastUpdatedAt: timestamp + } + }; +} + +const mockCapabilityDurations: Record< + Exclude, + Record +> = { + search: { + tmall: 420, + jd: 760 + }, + detail: { + tmall: 260, + jd: 320 + }, + reviews: { + tmall: 880, + jd: 980 + } +}; + +const mockLoginDurations: Record = { + tmall: 640, + jd: 720 +}; + +function resolveDefaultLayer( + platform: PlatformId, + capability: PlatformCapability +): StrategyLayer { + if (capability === "login") { + return "L3"; + } + + if (capability === "search") { + return platform === "jd" ? "L0" : "L1"; + } + + return capability === "detail" && platform === "jd" ? "L0" : "L1"; +} + +function getMockDurationMs( + platform: PlatformId, + capability: PlatformCapability, + outcome: StrategyAttemptOutcome +): number { + const baseDuration = + capability === "login" + ? mockLoginDurations[platform] + : mockCapabilityDurations[capability][platform]; + + switch (outcome) { + case "blocked": + return baseDuration + 180; + case "failed": + return baseDuration + 220; + case "no_result": + return Math.max(120, baseDuration - 140); + case "skipped": + return Math.max(80, baseDuration - 200); + default: + return baseDuration; + } +} + +const SESSION_TTL_HOURS = 24; +const SESSION_TTL_MS = SESSION_TTL_HOURS * 60 * 60 * 1000; + +type StoredSessionState = Omit & { + encryptedSnapshot: string | null; +}; + +function addDurationMs(timestamp: string, durationMs: number): string { + return new Date(Date.parse(timestamp) + durationMs).toISOString(); +} + +function createEncryptedSnapshot(platform: PlatformId, timestamp: string): string { + return `mock-aes-gcm-v1:${platform}:${Buffer.from(timestamp).toString("base64url")}`; +} + +function createMissingSession(platform: PlatformId): StoredSessionState { + return { + platform, + ready: false, + status: "missing", + searchRequirement: platformCatalogMap[platform].searchRequirement, + scope: "workspace", + ttlHours: SESSION_TTL_HOURS, + encryptedSnapshot: null + }; +} + +function createPreparedSession(platform: PlatformId, timestamp: string): StoredSessionState { + return { + ...createMissingSession(platform), + ready: true, + status: "ready", + lastPreparedAt: timestamp, + expiresAt: addDurationMs(timestamp, SESSION_TTL_MS), + encryptedSnapshot: createEncryptedSnapshot(platform, timestamp), + cipherLabel: "mock-aes-gcm-v1" + }; +} + type MockExecutionScenario = { outcome: Extract; mode: "once" | "always"; @@ -78,39 +222,63 @@ export class InMemoryTaskStore { private readonly tasks = new Map(); private readonly reports = new Map(); private readonly reportFingerprints = new Map(); - private readonly readiness = new Map(); + private readonly readiness = new Map(); + private readonly strategyAttempts = new Map(); + private readonly platformRunMetrics = new Map< + string, + Record + >(); + private readonly reportMetrics: ReportMetricRecord[] = []; + private readonly retentionMetrics: RetentionMetricRecord[] = []; + private readonly auditLogs: AuditLogRecord[] = []; private readonly executionScenarios = new Map< string, Partial> >(); constructor() { - this.readiness.set("tmall", { - platform: "tmall", - ready: true, - searchRequirement: "recommended", - lastPreparedAt: nowIso() - }); - this.readiness.set("jd", { - platform: "jd", - ready: false, - searchRequirement: "required" - }); + const timestamp = nowIso(); + this.readiness.set("tmall", createPreparedSession("tmall", timestamp)); + this.readiness.set("jd", createMissingSession("jd")); } getPlatformReadiness(): SessionReadinessRecord[] { return platforms.map((platform) => this.requireReadiness(platform)); } - preparePlatform(platform: PlatformId): SessionReadinessRecord { - const readiness = this.requireReadiness(platform); - const next: SessionReadinessRecord = { - ...readiness, - ready: true, - lastPreparedAt: nowIso() - }; + listSessions(): SessionStateRecord[] { + return platforms.map((platform) => this.getSession(platform)); + } + + getSession(platform: PlatformId): SessionStateRecord { + return this.toSessionRecord(this.requireSession(platform)); + } + + preparePlatform(platform: PlatformId): SessionStateRecord { + const timestamp = nowIso(); + const next = createPreparedSession(platform, timestamp); this.readiness.set(platform, next); - return next; + this.pushAudit("session.prepared", { + platform, + message: `${platformCatalogMap[platform].label} 会话已标记为预热完成。`, + metadata: { + search_requirement: next.searchRequirement, + expires_at: next.expiresAt ?? null + } + }); + return this.toSessionRecord(next); + } + + clearPlatformSession(platform: PlatformId): void { + const cleared = createMissingSession(platform); + this.readiness.set(platform, cleared); + this.pushAudit("session.cleared", { + platform, + message: `${platformCatalogMap[platform].label} 会话已清理。`, + metadata: { + search_requirement: cleared.searchRequirement + } + }); } createTask(input: CreateTaskInput): TaskRecord { @@ -141,6 +309,9 @@ export class InMemoryTaskStore { reportVersions: [] }; + this.strategyAttempts.set(taskId, []); + this.platformRunMetrics.set(taskId, createPlatformRunMetricsRecord(taskId, timestamp)); + if (Object.keys(executionScenarios).length > 0) { this.executionScenarios.set(taskId, executionScenarios); } @@ -184,6 +355,93 @@ export class InMemoryTaskStore { return this.tasks.get(taskId)?.platformCandidates; } + getTaskStrategyAttempts(taskId: string): StrategyAttemptRecord[] | undefined { + if (!this.tasks.has(taskId)) { + return undefined; + } + + return [...(this.strategyAttempts.get(taskId) ?? [])]; + } + + getTaskAuditLogs(taskId: string): AuditLogRecord[] | undefined { + if (!this.tasks.has(taskId)) { + return undefined; + } + + return this.auditLogs.filter((entry) => entry.taskId === taskId); + } + + getObservabilityOverview(): ObservabilityOverview { + const strategyAttempts = Array.from(this.strategyAttempts.values()).flat(); + const totalAttempts = strategyAttempts.length; + const searchAttempts = strategyAttempts.filter((attempt) => attempt.capability === "search"); + const successfulSearchAttempts = searchAttempts.filter( + (attempt) => attempt.outcome === "succeeded" + ); + const platformRuns = Array.from(this.platformRunMetrics.values()).flatMap((record) => + platforms.map((platform) => record[platform]) + ); + + return { + strategyAttempts: { + total: totalAttempts, + searchSuccessRate: + searchAttempts.length > 0 + ? Number( + ((successfulSearchAttempts.length / searchAttempts.length) * 100).toFixed(2) + ) + : 0, + browserFallbackShare: + totalAttempts > 0 + ? Number( + ( + (strategyAttempts.filter((attempt) => attempt.layer === "L3").length / + totalAttempts) * + 100 + ).toFixed(2) + ) + : 0, + byLayer: strategyLayers.map((layer) => ({ + layer, + count: strategyAttempts.filter((attempt) => attempt.layer === layer).length + })), + byOutcome: strategyAttemptOutcomes.map((outcome) => ({ + outcome, + count: strategyAttempts.filter((attempt) => attempt.outcome === outcome).length + })) + }, + platformRuns, + reports: { + published: this.reportMetrics.length, + unchanged: this.auditLogs.filter((entry) => entry.action === "report.unchanged").length, + sampleInsufficient: this.reportMetrics.filter((metric) => metric.sampleInsufficient).length, + partialFailures: this.reportMetrics.filter((metric) => metric.partialPlatformFailure).length + }, + retention: { + taskDeletes: this.retentionMetrics.filter((metric) => metric.action === "task_deleted") + .length, + cleanupRuns: this.retentionMetrics.filter((metric) => metric.action === "retention_cleanup") + .length, + deletedReports: this.retentionMetrics.reduce( + (sum, metric) => sum + metric.deletedReportCount, + 0 + ), + residualArtifacts: this.retentionMetrics.reduce( + (sum, metric) => sum + metric.residualArtifactCount, + 0 + ) + }, + audits: { + total: this.auditLogs.length, + recoveryActions: this.auditLogs.filter( + (entry) => + entry.action === "session.prepared" || entry.action === "task.recovery_completed" + ).length, + deleteActions: this.auditLogs.filter((entry) => entry.action === "task.deleted").length + } + }; + } + confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord { const task = this.requireTask(taskId); const selectionMap = new Map( @@ -253,12 +511,27 @@ export class InMemoryTaskStore { return task; } + this.incrementRetryCount(task.taskId, platform); + this.incrementRecoveryCount(task.taskId, platform); this.pushEvent( task, `platform.${platform}.retry_started`, `${platformCatalogMap[platform].label} 正在重新获取候选结果。` ); - this.runSearchForPlatform(task, run); + this.pushAudit("platform.retry_started", { + taskId, + platform, + message: `${platformCatalogMap[platform].label} 已发起候选重试。`, + metadata: { + previous_status: "SearchBlocked" + } + }); + this.pushAudit("task.recovery_completed", { + taskId, + platform, + message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新搜索。` + }); + this.runSearchForPlatform(task, run, "recovery"); const recoveredCandidateCount = task.platformCandidates[platform].length; task.taskStage = "confirmation"; task.taskStatus = "AwaitingConfirmation"; @@ -270,6 +543,18 @@ export class InMemoryTaskStore { ? `${platformCatalogMap[platform].label} 已恢复候选确认。` : `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。` ); + this.pushAudit("platform.retry_completed", { + taskId, + platform, + message: + recoveredCandidateCount > 0 + ? `${platformCatalogMap[platform].label} 候选重试已恢复。` + : `${platformCatalogMap[platform].label} 候选重试后仍无结果。`, + metadata: { + candidate_count: recoveredCandidateCount, + next_status: run.status + } + }); return task; } @@ -282,6 +567,16 @@ export class InMemoryTaskStore { return task; } + const previousStatus = run.status; + this.incrementRetryCount(task.taskId, platform); + if (previousStatus === "Blocked") { + this.incrementRecoveryCount(task.taskId, platform); + this.pushAudit("task.recovery_completed", { + taskId, + platform, + message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新执行。` + }); + } task.taskStatus = "Running"; task.taskStage = "session_check"; this.pushEvent( @@ -289,11 +584,28 @@ export class InMemoryTaskStore { `platform.${platform}.retry_started`, `${platformCatalogMap[platform].label} 正在执行定向重试。` ); + this.pushAudit("platform.retry_started", { + taskId, + platform, + message: `${platformCatalogMap[platform].label} 已发起平台级重试。`, + metadata: { + previous_status: previousStatus + } + }); - this.executeSelectedPlatforms(task, [run]); + this.executeSelectedPlatforms(task, [run], previousStatus === "Blocked" ? "recovery" : "retry"); task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); task.updatedAt = nowIso(); this.publishReportIfNeeded(task); + this.pushAudit("platform.retry_completed", { + taskId, + platform, + message: `${platformCatalogMap[platform].label} 平台级重试已结束。`, + metadata: { + next_status: run.status, + task_status: task.taskStatus + } + }); return task; } @@ -312,10 +624,29 @@ export class InMemoryTaskStore { } deleteTask(taskId: string): void { - this.requireTask(taskId); + const task = this.requireTask(taskId); + const deletedReportCount = this.reports.get(taskId)?.length ?? 0; + this.retentionMetrics.push({ + metricId: randomUUID(), + action: "task_deleted", + taskId, + deletedReportCount, + deletedArtifactCount: 0, + residualArtifactCount: 0, + recordedAt: nowIso() + }); + this.pushAudit("task.deleted", { + taskId, + message: `任务“${task.query}”及其关联报告已删除。`, + metadata: { + deleted_report_count: deletedReportCount + } + }); this.tasks.delete(taskId); this.reports.delete(taskId); this.reportFingerprints.delete(taskId); + this.strategyAttempts.delete(taskId); + this.platformRunMetrics.delete(taskId); this.executionScenarios.delete(taskId); } @@ -504,13 +835,28 @@ export class InMemoryTaskStore { }); } - private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void { + private runSearchForPlatform( + task: TaskRecord, + run: PlatformRunRecord, + trigger: StrategyAttemptTrigger = "system" + ): void { const readiness = this.requireReadiness(run.platform); if (readiness.searchRequirement === "required" && !readiness.ready) { run.status = "SearchBlocked"; run.reason = platformCatalogMap[run.platform].recoveryHint; run.candidateCount = 0; run.lastUpdatedAt = nowIso(); + this.recordStrategyAttempt( + task.taskId, + run.platform, + "search", + "blocked", + trigger, + `${platformCatalogMap[run.platform].label} 搜索前检查缺少有效会话。`, + { + errorType: "session_required" + } + ); this.pushEvent( task, `platform.${run.platform}.search_blocked`, @@ -531,6 +877,21 @@ export class InMemoryTaskStore { ? undefined : `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`; run.lastUpdatedAt = nowIso(); + this.recordStrategyAttempt( + task.taskId, + run.platform, + "search", + candidates.length > 0 ? "succeeded" : "no_result", + trigger, + candidates.length > 0 + ? `${platformCatalogMap[run.platform].label} 已返回候选列表。` + : `${platformCatalogMap[run.platform].label} 本轮搜索没有候选结果。`, + candidates.length > 0 + ? undefined + : { + errorType: "no_candidates" + } + ); this.pushEvent( task, `platform.${run.platform}.searched`, @@ -542,7 +903,8 @@ export class InMemoryTaskStore { private executeSelectedPlatforms( task: TaskRecord, - runs: PlatformRunRecord[] + runs: PlatformRunRecord[], + trigger: StrategyAttemptTrigger = "system" ): void { task.taskStage = "crawl"; @@ -552,6 +914,18 @@ export class InMemoryTaskStore { run.status = "Blocked"; run.reason = platformCatalogMap[run.platform].recoveryHint; run.lastUpdatedAt = nowIso(); + this.recordStrategyAttempt( + task.taskId, + run.platform, + "login", + "blocked", + trigger, + `${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`, + { + layer: "L3", + errorType: "session_required" + } + ); this.pushEvent( task, `platform.${run.platform}.blocked`, @@ -565,6 +939,18 @@ export class InMemoryTaskStore { run.status = "Blocked"; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`; run.lastUpdatedAt = nowIso(); + this.recordStrategyAttempt( + task.taskId, + run.platform, + "login", + "blocked", + trigger, + `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要浏览器恢复。`, + { + layer: "L3", + errorType: "platform_blocked" + } + ); this.pushEvent( task, `platform.${run.platform}.blocked`, @@ -581,8 +967,27 @@ export class InMemoryTaskStore { `platform.${run.platform}.running`, `${platformCatalogMap[run.platform].label} 开始抓取商品与评论。` ); + this.recordStrategyAttempt( + task.taskId, + run.platform, + "detail", + "succeeded", + trigger, + `${platformCatalogMap[run.platform].label} 已通过 ${resolveDefaultLayer(run.platform, "detail")} 抓取商品详情。` + ); if (mockOutcome === "Failed") { + this.recordStrategyAttempt( + task.taskId, + run.platform, + "reviews", + "failed", + trigger, + `${platformCatalogMap[run.platform].label} 评论抓取失败,可稍后定向重试。`, + { + errorType: "mock_failure" + } + ); run.status = "Failed"; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`; run.lastUpdatedAt = nowIso(); @@ -594,6 +999,14 @@ export class InMemoryTaskStore { continue; } + this.recordStrategyAttempt( + task.taskId, + run.platform, + "reviews", + "succeeded", + trigger, + `${platformCatalogMap[run.platform].label} 已完成评论抓取与抽样。` + ); run.status = "Completed"; run.reason = undefined; run.lastUpdatedAt = nowIso(); @@ -635,6 +1048,13 @@ export class InMemoryTaskStore { if (lastFingerprint === fingerprint) { this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。"); + this.pushAudit("report.unchanged", { + taskId: task.taskId, + message: "报告结果未变化,继续沿用当前版本。", + metadata: { + task_status: task.taskStatus + } + }); return false; } @@ -645,7 +1065,28 @@ export class InMemoryTaskStore { task.reportVersions = [...task.reportVersions, report.report_version]; task.defaultReportVersion = report.report_version; task.latestSuccessfulReportVersion = report.report_version; + this.reportMetrics.push({ + taskId: task.taskId, + reportVersion: report.report_version, + taskStatus: task.taskStatus, + selectedLinkCount: report.product_snapshot.selected_link_count, + reviewSampleCount: report.product_snapshot.review_sample_count, + blockedPlatforms: report.quality_flags.blocked_platforms, + failedPlatforms: report.quality_flags.failed_platforms, + sampleInsufficient: report.quality_flags.sample_insufficient, + partialPlatformFailure: report.quality_flags.partial_platform_failure, + generatedAt: report.generated_at + }); this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`); + this.pushAudit("report.published", { + taskId: task.taskId, + message: `报告 v${report.report_version} 已生成。`, + metadata: { + report_version: report.report_version, + task_status: task.taskStatus, + review_sample_count: report.product_snapshot.review_sample_count + } + }); return true; } @@ -691,6 +1132,98 @@ export class InMemoryTaskStore { return scenario.outcome; } + private recordStrategyAttempt( + taskId: string, + platform: PlatformId, + capability: PlatformCapability, + outcome: StrategyAttemptOutcome, + trigger: StrategyAttemptTrigger, + detail: string, + options?: { + layer?: StrategyLayer; + errorType?: string; + } + ): StrategyAttemptRecord { + const durationMs = getMockDurationMs(platform, capability, outcome); + const finishedAt = nowIso(); + const startedAt = new Date(Date.parse(finishedAt) - durationMs).toISOString(); + const attempt: StrategyAttemptRecord = { + attemptId: randomUUID(), + taskId, + platform, + capability, + layer: options?.layer ?? resolveDefaultLayer(platform, capability), + outcome, + trigger, + startedAt, + finishedAt, + durationMs, + errorType: options?.errorType, + detail + }; + const attempts = this.strategyAttempts.get(taskId) ?? []; + attempts.push(attempt); + this.strategyAttempts.set(taskId, attempts); + this.addCapabilityDuration(taskId, platform, capability, durationMs); + return attempt; + } + + private addCapabilityDuration( + taskId: string, + platform: PlatformId, + capability: PlatformCapability, + durationMs: number + ): void { + const metrics = this.requirePlatformMetrics(taskId); + const target = metrics[platform]; + + if (capability === "search") { + target.searchDurationMs += durationMs; + } else if (capability === "detail") { + target.detailDurationMs += durationMs; + } else if (capability === "reviews") { + target.reviewsDurationMs += durationMs; + } + + target.lastUpdatedAt = nowIso(); + } + + private incrementRetryCount(taskId: string, platform: PlatformId): void { + const metrics = this.requirePlatformMetrics(taskId); + metrics[platform].retryCount += 1; + metrics[platform].lastUpdatedAt = nowIso(); + } + + private incrementRecoveryCount(taskId: string, platform: PlatformId): void { + const metrics = this.requirePlatformMetrics(taskId); + metrics[platform].recoveryCount += 1; + metrics[platform].lastUpdatedAt = nowIso(); + } + + private pushAudit( + action: AuditAction, + input: { + message: string; + taskId?: string; + platform?: PlatformId; + metadata?: AuditLogRecord["metadata"]; + } + ): void { + if (!auditActions.includes(action)) { + throw new Error(`Unsupported audit action ${action}.`); + } + + this.auditLogs.push({ + auditId: randomUUID(), + taskId: input.taskId, + platform: input.platform, + action, + message: input.message, + createdAt: nowIso(), + metadata: input.metadata + }); + } + private pushEvent(task: TaskRecord, type: string, message: string): void { const event: TaskEventRecord = { eventId: randomUUID(), @@ -710,11 +1243,82 @@ export class InMemoryTaskStore { return task; } - private requireReadiness(platform: PlatformId): SessionReadinessRecord { - const readiness = this.readiness.get(platform); - if (!readiness) { + private requirePlatformMetrics( + taskId: string + ): Record { + const metrics = this.platformRunMetrics.get(taskId); + if (!metrics) { + throw new Error(`Platform metrics for task ${taskId} not found.`); + } + return metrics; + } + + private toSessionRecord(session: StoredSessionState): SessionStateRecord { + return { + platform: session.platform, + ready: session.ready, + status: session.status, + searchRequirement: session.searchRequirement, + scope: session.scope, + ttlHours: session.ttlHours, + lastPreparedAt: session.lastPreparedAt, + expiresAt: session.expiresAt, + encryptedSnapshotAvailable: Boolean(session.encryptedSnapshot), + cipherLabel: session.encryptedSnapshot ? session.cipherLabel : undefined + }; + } + + private toReadiness(session: StoredSessionState): SessionReadinessRecord { + return { + platform: session.platform, + ready: session.ready, + status: session.status, + searchRequirement: session.searchRequirement, + reason: this.getSessionReason(session), + lastPreparedAt: session.lastPreparedAt, + expiresAt: session.expiresAt + }; + } + + private getSessionReason(session: StoredSessionState): string { + switch (session.status) { + case "ready": + return "当前工作区存在可复用会话,创建任务时会再次校验。"; + case "expired": + return "最近一次会话已过期,请重新完成会话准备。"; + default: + return session.searchRequirement === "required" + ? platformCatalogMap[session.platform].recoveryHint + : "当前没有可复用会话,建议先预热以提升搜索稳定性。"; + } + } + + private requireSession(platform: PlatformId): StoredSessionState { + const session = this.readiness.get(platform); + if (!session) { throw new Error(`Platform ${platform} not found.`); } - return readiness; + + if ( + session.status === "ready" && + session.expiresAt && + Date.parse(session.expiresAt) <= Date.now() + ) { + const expired: StoredSessionState = { + ...session, + ready: false, + status: "expired", + encryptedSnapshot: null, + cipherLabel: undefined + }; + this.readiness.set(platform, expired); + return expired; + } + + return session; + } + + private requireReadiness(platform: PlatformId): SessionReadinessRecord { + return this.toReadiness(this.requireSession(platform)); } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f1de285..6a85c23 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,8 @@ import { type CandidateRecord, type PlatformId, type PlatformStatus, + type SessionReadinessRecord, + type SessionStateRecord, type TaskRecord, type TaskStatus } from "@cross-ai/domain"; @@ -22,11 +24,13 @@ import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./componen import { TaskContextHeader } from "./components/TaskContextHeader"; import { TaskSpine } from "./components/TaskSpine"; import { + clearPlatformSession, confirmTask, createTask, deleteTask, getHistoryTasks, getPlatformReadiness, + getPlatformSession, getTask, getTaskCandidates, getTaskReport, @@ -118,12 +122,65 @@ function getNoReportSummary(task: TaskRecord) { } } -function NewTaskPage() { +function formatTimestamp(timestamp?: string) { + if (!timestamp) { + return "暂无"; + } + + return new Date(timestamp).toLocaleString("zh-CN"); +} + +function getSearchRequirementLabel( + searchRequirement: SessionReadinessRecord["searchRequirement"] +) { + switch (searchRequirement) { + case "required": + return "需准备会话"; + case "recommended": + return "建议预热"; + default: + return "无需会话"; + } +} + +function getReadinessSummary(readiness: SessionReadinessRecord) { + if (readiness.status === "ready") { + return readiness.expiresAt + ? `当前工作区已有可复用会话,有效至 ${formatTimestamp(readiness.expiresAt)}。` + : readiness.reason; + } + + if (readiness.status === "expired") { + return `最近一次会话已过期,上次准备时间为 ${formatTimestamp(readiness.lastPreparedAt)}。`; + } + + return readiness.reason; +} + +function getSessionSnapshotSummary(session: SessionStateRecord) { + if (session.status === "ready") { + return session.expiresAt + ? `已保存加密快照,可复用至 ${formatTimestamp(session.expiresAt)}。` + : "已保存加密快照,可用于当前工作区复用。"; + } + + if (session.status === "expired") { + return "最近一次会话已过期,需重新完成会话准备。"; + } + + return "当前还没有可复用会话快照。"; +} + +export function NewTaskPage() { const navigate = useNavigate(); const readinessQuery = useQuery({ queryKey: ["platform-readiness"], queryFn: getPlatformReadiness }); + const historyQuery = useQuery({ + queryKey: ["history"], + queryFn: getHistoryTasks + }); const [query, setQuery] = useState(""); const [perLinkLimit, setPerLinkLimit] = useState(100); const [taskTotalLimit, setTaskTotalLimit] = useState(500); @@ -134,6 +191,7 @@ function NewTaskPage() { navigate(`/tasks/${task.taskId}/confirm`); } }); + const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4); return ( @@ -209,26 +267,72 @@ function NewTaskPage() { status={platform.ready ? "Completed" : "SearchBlocked"} /> -

{platformCatalogMap[platform.platform].description}

- - 进入会话准备 - +

+ 搜索要求:{getSearchRequirementLabel(platform.searchRequirement)} +

+

{getReadinessSummary(platform)}

+
+ + 最近准备:{formatTimestamp(platform.lastPreparedAt)} + + + {platform.expiresAt + ? `有效至 ${formatTimestamp(platform.expiresAt)}` + : "当前无有效期中的会话"} + +
+ ))} -
-

Scope Reminder

-

P0 当前不做什么

-
    -
  • 不做自动绕过风控。
  • -
  • 不做无人工确认的同款判断。
  • -
  • 当前工作台只覆盖天猫、京东。
  • -
+
+
+

Recent Tasks

+

最近任务捷径

+
+ {recentTasks.length > 0 ? ( + recentTasks.map((task) => ( + +
+ {task.query} + +
+

{new Date(task.updatedAt).toLocaleString("zh-CN")}

+
+ )) + ) : ( +
还没有历史任务,先创建第一条分析任务。
+ )} +
+
+ +
+

Scope Reminder

+

P0 当前不做什么

+
    +
  • 不做自动绕过风控。
  • +
  • 不做无人工确认的同款判断。
  • +
  • 当前工作台只覆盖天猫、京东。
  • +
+
@@ -983,34 +1087,87 @@ export function HistoryPage() { ); } -function SessionPreparePage() { +export function SessionPreparePage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { platform = "tmall" } = useParams(); const [searchParams] = useSearchParams(); const from = searchParams.get("from") ?? "/tasks/new"; + const platformId = platform as PlatformId; + const sessionQuery = useQuery({ + queryKey: ["session", platformId], + queryFn: () => getPlatformSession(platformId) + }); const prepareMutation = useMutation({ - mutationFn: () => preparePlatform(platform as PlatformId), + mutationFn: () => preparePlatform(platformId), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }), + queryClient.invalidateQueries({ queryKey: ["session", platformId] }) + ]); navigate(from); } }); + const clearMutation = useMutation({ + mutationFn: () => clearPlatformSession(platformId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }), + queryClient.invalidateQueries({ queryKey: ["session", platformId] }) + ]); + } + }); + const session = sessionQuery.data?.session; return (

Session Console

-

{platformCatalogMap[platform as PlatformId].label} 会话准备

-

{platformCatalogMap[platform as PlatformId].recoveryHint}

+

{platformCatalogMap[platformId].label} 会话准备

+

{platformCatalogMap[platformId].recoveryHint}

Remote Browser Viewport
当前模式:prepare

本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。

- +
+
+ 当前状态 + +
+
+ 搜索要求 + {getSearchRequirementLabel(platformCatalogMap[platformId].searchRequirement)} +
+
+ 会话快照 + {session?.encryptedSnapshotAvailable ? "已加密保存" : "尚未生成"} +
+
+ 有效期 + {session?.expiresAt ? formatTimestamp(session.expiresAt) : "暂无"} +
+

{session ? getSessionSnapshotSummary(session) : "正在读取当前会话状态..."}

+

完成后将返回:{from}

+
+
+ + +
diff --git a/apps/web/src/HistoryPage.test.tsx b/apps/web/src/HistoryPage.test.tsx index 23a3ace..6c53532 100644 --- a/apps/web/src/HistoryPage.test.tsx +++ b/apps/web/src/HistoryPage.test.tsx @@ -6,11 +6,13 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./lib/api", () => ({ + clearPlatformSession: vi.fn(), confirmTask: vi.fn(), createTask: vi.fn(), deleteTask: vi.fn(), getHistoryTasks: vi.fn(), getPlatformReadiness: vi.fn(), + getPlatformSession: vi.fn(), getTask: vi.fn(), getTaskCandidates: vi.fn(), getTaskReport: vi.fn(), diff --git a/apps/web/src/NewTaskPage.test.tsx b/apps/web/src/NewTaskPage.test.tsx new file mode 100644 index 0000000..c30498e --- /dev/null +++ b/apps/web/src/NewTaskPage.test.tsx @@ -0,0 +1,158 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./lib/api", () => ({ + clearPlatformSession: vi.fn(), + confirmTask: vi.fn(), + createTask: vi.fn(), + deleteTask: vi.fn(), + getHistoryTasks: vi.fn(), + getPlatformReadiness: vi.fn(), + getPlatformSession: vi.fn(), + getTask: vi.fn(), + getTaskCandidates: vi.fn(), + getTaskReport: vi.fn(), + preparePlatform: vi.fn(), + retryTaskPlatform: vi.fn() +})); + +import { NewTaskPage, SessionPreparePage } from "./App"; +import { + clearPlatformSession, + getHistoryTasks, + getPlatformReadiness, + getPlatformSession, + preparePlatform +} from "./lib/api"; + +function renderWithProviders(node: ReactNode, initialEntries?: string[]) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } + }); + + return render( + + {initialEntries ? ( + {node} + ) : ( + {node} + )} + + ); +} + +describe("task composer and session console", () => { + beforeEach(() => { + vi.mocked(getPlatformReadiness).mockResolvedValue({ + platforms: [ + { + platform: "tmall", + ready: true, + status: "ready", + searchRequirement: "recommended", + reason: "当前工作区存在可复用会话,创建任务时会再次校验。", + lastPreparedAt: "2026-04-02T12:00:00.000Z", + expiresAt: "2026-04-03T12:00:00.000Z" + }, + { + platform: "jd", + ready: false, + status: "missing", + searchRequirement: "required", + reason: "需要先完成会话准备,否则系统会标记为 SearchBlocked。" + } + ] + } as any); + vi.mocked(getHistoryTasks).mockResolvedValue({ + tasks: [ + { + taskId: "task-1", + query: "Nintendo Switch 2", + taskStatus: "Completed", + updatedAt: "2026-04-02T12:00:00.000Z", + hasReport: true, + defaultReportVersion: 2, + failedPlatforms: [], + blockedPlatforms: [] + }, + { + taskId: "task-2", + query: "DJI Pocket 3", + taskStatus: "AwaitingConfirmation", + updatedAt: "2026-04-02T11:30:00.000Z", + hasReport: false, + failedPlatforms: [], + blockedPlatforms: ["jd"] + } + ] + } as any); + vi.mocked(getPlatformSession).mockResolvedValue({ + session: { + platform: "jd", + ready: true, + status: "ready", + searchRequirement: "required", + scope: "workspace", + ttlHours: 24, + lastPreparedAt: "2026-04-02T10:00:00.000Z", + expiresAt: "2026-04-03T10:00:00.000Z", + encryptedSnapshotAvailable: true, + cipherLabel: "mock-aes-gcm-v1" + } + } as any); + vi.mocked(clearPlatformSession).mockResolvedValue(undefined); + vi.mocked(preparePlatform).mockResolvedValue({ + platform: "jd", + session_ready: true, + status: "ready", + last_prepared_at: "2026-04-02T10:00:00.000Z", + expires_at: "2026-04-03T10:00:00.000Z", + encrypted_snapshot_available: true + } as any); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows readiness details and recent task shortcuts on the new task page", async () => { + renderWithProviders(); + + expect(await screen.findByText("最近任务捷径")).toBeInTheDocument(); + expect(await screen.findByText("Nintendo Switch 2")).toBeInTheDocument(); + expect(await screen.findByText("DJI Pocket 3")).toBeInTheDocument(); + expect(await screen.findByText("搜索要求:建议预热")).toBeInTheDocument(); + expect( + await screen.findByText(/当前工作区已有可复用会话,有效至/) + ).toBeInTheDocument(); + }); + + it("shows session details and allows clearing the current session", async () => { + const user = userEvent.setup(); + + renderWithProviders( + + } path="/sessions/:platform/prepare" /> + , + ["/sessions/jd/prepare?from=/tasks/new"] + ); + + expect(await screen.findByText("已加密保存")).toBeInTheDocument(); + expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "清理当前会话" })); + + await waitFor(() => { + expect(clearPlatformSession).toHaveBeenCalledWith("jd"); + }); + }); +}); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 296e524..a48f339 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -220,7 +220,8 @@ a { .candidate-card, .metric-card, .insight-card, -.evidence-card { +.evidence-card, +.mini-task-link { border: 1px solid rgba(31, 42, 48, 0.08); border-radius: 18px; background: var(--bg-elevated); @@ -231,7 +232,8 @@ a { .history-card, .metric-card, .insight-card, -.evidence-card { +.evidence-card, +.mini-task-link { padding: 16px; } @@ -256,6 +258,37 @@ a { flex-wrap: wrap; } +.readiness-card__caption { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; +} + +.readiness-card__meta { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.mini-task-link { + display: grid; + gap: 8px; + color: inherit; +} + +.mini-task-link__topline { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; +} + +.mini-task-link p { + margin: 0; + color: var(--text-secondary); +} + .task-context-header { padding: 20px 0 0; } @@ -424,6 +457,12 @@ a { background: rgba(20, 108, 110, 0.08); } +.inline-note--subtle { + padding: 8px 10px; + background: rgba(31, 42, 48, 0.06); + color: var(--text-secondary); +} + .metric-card { display: grid; gap: 6px; @@ -568,6 +607,31 @@ a { color: var(--text-secondary); } +.session-details { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 16px; + background: rgba(31, 42, 48, 0.04); +} + +.session-details p { + margin: 0; + color: var(--text-secondary); +} + +.session-details__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.session-return-target { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; +} + .list { margin: 0; padding-left: 18px; diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index d0965c7..0add2cb 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -4,6 +4,27 @@ export type PlatformId = (typeof platforms)[number]; export const searchRequirements = ["none", "recommended", "required"] as const; export type SearchRequirement = (typeof searchRequirements)[number]; +export const sessionStatuses = ["missing", "ready", "expired"] as const; +export type SessionStatus = (typeof sessionStatuses)[number]; + +export const strategyLayers = ["L0", "L1", "L2", "L3"] as const; +export type StrategyLayer = (typeof strategyLayers)[number]; + +export const platformCapabilities = ["search", "detail", "reviews", "login"] as const; +export type PlatformCapability = (typeof platformCapabilities)[number]; + +export const strategyAttemptOutcomes = [ + "succeeded", + "blocked", + "failed", + "no_result", + "skipped" +] as const; +export type StrategyAttemptOutcome = (typeof strategyAttemptOutcomes)[number]; + +export const strategyAttemptTriggers = ["system", "retry", "recovery"] as const; +export type StrategyAttemptTrigger = (typeof strategyAttemptTriggers)[number]; + export const taskStatuses = [ "Draft", "Searching", @@ -64,3 +85,18 @@ export type SampleFlag = (typeof sampleFlags)[number]; export const evidenceSourceTypes = ["product", "review"] as const; export type EvidenceSourceType = (typeof evidenceSourceTypes)[number]; + +export const retentionActions = ["task_deleted", "retention_cleanup"] as const; +export type RetentionAction = (typeof retentionActions)[number]; + +export const auditActions = [ + "session.prepared", + "session.cleared", + "task.recovery_completed", + "platform.retry_started", + "platform.retry_completed", + "task.deleted", + "report.published", + "report.unchanged" +] as const; +export type AuditAction = (typeof auditActions)[number]; diff --git a/packages/domain/src/models.ts b/packages/domain/src/models.ts index cfa2055..a10403d 100644 --- a/packages/domain/src/models.ts +++ b/packages/domain/src/models.ts @@ -1,7 +1,15 @@ import type { + AuditAction, + PlatformCapability, PlatformId, PlatformStatus, SearchRequirement, + ReportableTaskStatus, + RetentionAction, + SessionStatus, + StrategyAttemptOutcome, + StrategyAttemptTrigger, + StrategyLayer, TaskStage, TaskStatus } from "./enums"; @@ -46,8 +54,120 @@ export interface TaskEventRecord { export interface SessionReadinessRecord { platform: PlatformId; ready: boolean; + status: SessionStatus; searchRequirement: SearchRequirement; + reason: string; lastPreparedAt?: string | undefined; + expiresAt?: string | undefined; +} + +export interface SessionStateRecord { + platform: PlatformId; + ready: boolean; + status: SessionStatus; + searchRequirement: SearchRequirement; + scope: "workspace"; + ttlHours: number; + lastPreparedAt?: string | undefined; + expiresAt?: string | undefined; + encryptedSnapshotAvailable: boolean; + cipherLabel?: string | undefined; +} + +export interface StrategyAttemptRecord { + attemptId: string; + taskId: string; + platform: PlatformId; + capability: PlatformCapability; + layer: StrategyLayer; + outcome: StrategyAttemptOutcome; + trigger: StrategyAttemptTrigger; + startedAt: string; + finishedAt: string; + durationMs: number; + errorType?: string | undefined; + detail: string; +} + +export interface PlatformRunMetricRecord { + taskId: string; + platform: PlatformId; + searchDurationMs: number; + detailDurationMs: number; + reviewsDurationMs: number; + retryCount: number; + recoveryCount: number; + lastUpdatedAt: string; +} + +export interface ReportMetricRecord { + taskId: string; + reportVersion: number; + taskStatus: ReportableTaskStatus; + selectedLinkCount: number; + reviewSampleCount: number; + blockedPlatforms: PlatformId[]; + failedPlatforms: PlatformId[]; + sampleInsufficient: boolean; + partialPlatformFailure: boolean; + generatedAt: string; +} + +export interface RetentionMetricRecord { + metricId: string; + action: RetentionAction; + taskId?: string | undefined; + deletedReportCount: number; + deletedArtifactCount: number; + residualArtifactCount: number; + recordedAt: string; +} + +export type AuditMetadataValue = string | number | boolean | null; +export type AuditMetadata = Record; + +export interface AuditLogRecord { + auditId: string; + taskId?: string | undefined; + platform?: PlatformId | undefined; + action: AuditAction; + message: string; + createdAt: string; + metadata?: AuditMetadata | undefined; +} + +export interface ObservabilityOverview { + strategyAttempts: { + total: number; + searchSuccessRate: number; + browserFallbackShare: number; + byLayer: Array<{ + layer: StrategyLayer; + count: number; + }>; + byOutcome: Array<{ + outcome: StrategyAttemptOutcome; + count: number; + }>; + }; + platformRuns: PlatformRunMetricRecord[]; + reports: { + published: number; + unchanged: number; + sampleInsufficient: number; + partialFailures: number; + }; + retention: { + taskDeletes: number; + cleanupRuns: number; + deletedReports: number; + residualArtifacts: number; + }; + audits: { + total: number; + recoveryActions: number; + deleteActions: number; + }; } export interface TaskRecord {