feat: 补齐会话中心 v1 与任务创建准备流

This commit is contained in:
renzhiye 2026-04-02 18:47:19 +08:00
parent 29cea8b0aa
commit 145f958663
9 changed files with 1462 additions and 59 deletions

View File

@ -49,6 +49,46 @@ describe("API server", () => {
await app.close(); 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 () => { it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -82,6 +122,102 @@ describe("API server", () => {
await app.close(); 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 () => { it("supports NoSelection terminal state when user confirms nothing", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -181,6 +317,62 @@ describe("API server", () => {
await app.close(); 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 () => { it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -340,6 +532,16 @@ describe("API server", () => {
.tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId) .tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId)
).toBe(false); ).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(); await app.close();
}); });
}); });

View File

@ -23,18 +23,50 @@ export function createServer() {
platforms: store.getPlatformReadiness() 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<{ app.post<{
Params: { platform: PlatformId }; Params: { platform: PlatformId };
}>("/api/platforms/:platform/prepare", async (request, reply) => { }>("/api/platforms/:platform/prepare", async (request, reply) => {
const readiness = store.preparePlatform(request.params.platform); const session = store.preparePlatform(request.params.platform);
reply.code(200); reply.code(200);
return { return {
platform: readiness.platform, platform: session.platform,
session_ready: readiness.ready, session_ready: session.ready,
last_prepared_at: readiness.lastPreparedAt 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<{ app.post<{
Body: CreateTaskInput; Body: CreateTaskInput;
}>("/api/tasks", async (request, reply) => { }>("/api/tasks", async (request, reply) => {
@ -78,6 +110,30 @@ export function createServer() {
return { candidates }; 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<{ app.post<{
Params: { taskId: string }; Params: { taskId: string };
Body: ConfirmTaskPayload; Body: ConfirmTaskPayload;
@ -120,6 +176,10 @@ export function createServer() {
tasks: store.listHistory() tasks: store.listHistory()
})); }));
app.get("/api/observability/overview", async () => ({
overview: store.getObservabilityOverview()
}));
app.get<{ app.get<{
Params: { taskId: string }; Params: { taskId: string };
}>("/api/tasks/:taskId/events", async (request, reply) => { }>("/api/tasks/:taskId/events", async (request, reply) => {

View File

@ -1,17 +1,32 @@
import { import {
auditActions,
deriveTaskStatusFromConfirmedPlatforms, deriveTaskStatusFromConfirmedPlatforms,
mapPlatformStatusToExecutionStatus, mapPlatformStatusToExecutionStatus,
platformCatalog, platformCatalog,
platformCatalogMap, platformCatalogMap,
platforms, platforms,
strategyAttemptOutcomes,
strategyLayers,
type AuditAction,
type AuditLogRecord,
type CandidateRecord, type CandidateRecord,
type ConfirmTaskPayload, type ConfirmTaskPayload,
type CreateTaskInput, type CreateTaskInput,
type HistoryTaskRecord, type HistoryTaskRecord,
type ObservabilityOverview,
type PlatformId, type PlatformId,
type PlatformCapability,
type PlatformRunMetricRecord,
type PlatformRunRecord, type PlatformRunRecord,
type PlatformStatus, type PlatformStatus,
type ReportMetricRecord,
type RetentionMetricRecord,
type SessionReadinessRecord, type SessionReadinessRecord,
type SessionStateRecord,
type StrategyAttemptOutcome,
type StrategyAttemptRecord,
type StrategyAttemptTrigger,
type StrategyLayer,
type TaskEventRecord, type TaskEventRecord,
type TaskRecord type TaskRecord
} from "@cross-ai/domain"; } from "@cross-ai/domain";
@ -31,6 +46,135 @@ function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]>
}; };
} }
function createPlatformRunMetricsRecord(
taskId: string,
timestamp: string
): Record<PlatformId, PlatformRunMetricRecord> {
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<PlatformCapability, "login">,
Record<PlatformId, number>
> = {
search: {
tmall: 420,
jd: 760
},
detail: {
tmall: 260,
jd: 320
},
reviews: {
tmall: 880,
jd: 980
}
};
const mockLoginDurations: Record<PlatformId, number> = {
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<SessionStateRecord, "encryptedSnapshotAvailable"> & {
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 = { type MockExecutionScenario = {
outcome: Extract<PlatformStatus, "Blocked" | "Failed">; outcome: Extract<PlatformStatus, "Blocked" | "Failed">;
mode: "once" | "always"; mode: "once" | "always";
@ -78,39 +222,63 @@ export class InMemoryTaskStore {
private readonly tasks = new Map<string, TaskRecord>(); private readonly tasks = new Map<string, TaskRecord>();
private readonly reports = new Map<string, ReportSnapshot[]>(); private readonly reports = new Map<string, ReportSnapshot[]>();
private readonly reportFingerprints = new Map<string, string[]>(); private readonly reportFingerprints = new Map<string, string[]>();
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>(); private readonly readiness = new Map<PlatformId, StoredSessionState>();
private readonly strategyAttempts = new Map<string, StrategyAttemptRecord[]>();
private readonly platformRunMetrics = new Map<
string,
Record<PlatformId, PlatformRunMetricRecord>
>();
private readonly reportMetrics: ReportMetricRecord[] = [];
private readonly retentionMetrics: RetentionMetricRecord[] = [];
private readonly auditLogs: AuditLogRecord[] = [];
private readonly executionScenarios = new Map< private readonly executionScenarios = new Map<
string, string,
Partial<Record<PlatformId, MockExecutionScenario>> Partial<Record<PlatformId, MockExecutionScenario>>
>(); >();
constructor() { constructor() {
this.readiness.set("tmall", { const timestamp = nowIso();
platform: "tmall", this.readiness.set("tmall", createPreparedSession("tmall", timestamp));
ready: true, this.readiness.set("jd", createMissingSession("jd"));
searchRequirement: "recommended",
lastPreparedAt: nowIso()
});
this.readiness.set("jd", {
platform: "jd",
ready: false,
searchRequirement: "required"
});
} }
getPlatformReadiness(): SessionReadinessRecord[] { getPlatformReadiness(): SessionReadinessRecord[] {
return platforms.map((platform) => this.requireReadiness(platform)); return platforms.map((platform) => this.requireReadiness(platform));
} }
preparePlatform(platform: PlatformId): SessionReadinessRecord { listSessions(): SessionStateRecord[] {
const readiness = this.requireReadiness(platform); return platforms.map((platform) => this.getSession(platform));
const next: SessionReadinessRecord = { }
...readiness,
ready: true, getSession(platform: PlatformId): SessionStateRecord {
lastPreparedAt: nowIso() return this.toSessionRecord(this.requireSession(platform));
}; }
preparePlatform(platform: PlatformId): SessionStateRecord {
const timestamp = nowIso();
const next = createPreparedSession(platform, timestamp);
this.readiness.set(platform, next); 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 { createTask(input: CreateTaskInput): TaskRecord {
@ -141,6 +309,9 @@ export class InMemoryTaskStore {
reportVersions: [] reportVersions: []
}; };
this.strategyAttempts.set(taskId, []);
this.platformRunMetrics.set(taskId, createPlatformRunMetricsRecord(taskId, timestamp));
if (Object.keys(executionScenarios).length > 0) { if (Object.keys(executionScenarios).length > 0) {
this.executionScenarios.set(taskId, executionScenarios); this.executionScenarios.set(taskId, executionScenarios);
} }
@ -184,6 +355,93 @@ export class InMemoryTaskStore {
return this.tasks.get(taskId)?.platformCandidates; 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 { confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord {
const task = this.requireTask(taskId); const task = this.requireTask(taskId);
const selectionMap = new Map( const selectionMap = new Map(
@ -253,12 +511,27 @@ export class InMemoryTaskStore {
return task; return task;
} }
this.incrementRetryCount(task.taskId, platform);
this.incrementRecoveryCount(task.taskId, platform);
this.pushEvent( this.pushEvent(
task, task,
`platform.${platform}.retry_started`, `platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在重新获取候选结果。` `${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; const recoveredCandidateCount = task.platformCandidates[platform].length;
task.taskStage = "confirmation"; task.taskStage = "confirmation";
task.taskStatus = "AwaitingConfirmation"; task.taskStatus = "AwaitingConfirmation";
@ -270,6 +543,18 @@ export class InMemoryTaskStore {
? `${platformCatalogMap[platform].label} 已恢复候选确认。` ? `${platformCatalogMap[platform].label} 已恢复候选确认。`
: `${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; return task;
} }
@ -282,6 +567,16 @@ export class InMemoryTaskStore {
return task; 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.taskStatus = "Running";
task.taskStage = "session_check"; task.taskStage = "session_check";
this.pushEvent( this.pushEvent(
@ -289,11 +584,28 @@ export class InMemoryTaskStore {
`platform.${platform}.retry_started`, `platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在执行定向重试。` `${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.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso(); task.updatedAt = nowIso();
this.publishReportIfNeeded(task); 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; return task;
} }
@ -312,10 +624,29 @@ export class InMemoryTaskStore {
} }
deleteTask(taskId: string): void { 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.tasks.delete(taskId);
this.reports.delete(taskId); this.reports.delete(taskId);
this.reportFingerprints.delete(taskId); this.reportFingerprints.delete(taskId);
this.strategyAttempts.delete(taskId);
this.platformRunMetrics.delete(taskId);
this.executionScenarios.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); const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) { if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "SearchBlocked"; run.status = "SearchBlocked";
run.reason = platformCatalogMap[run.platform].recoveryHint; run.reason = platformCatalogMap[run.platform].recoveryHint;
run.candidateCount = 0; run.candidateCount = 0;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"search",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 搜索前检查缺少有效会话。`,
{
errorType: "session_required"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.search_blocked`, `platform.${run.platform}.search_blocked`,
@ -531,6 +877,21 @@ export class InMemoryTaskStore {
? undefined ? undefined
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`; : `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
run.lastUpdatedAt = nowIso(); 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( this.pushEvent(
task, task,
`platform.${run.platform}.searched`, `platform.${run.platform}.searched`,
@ -542,7 +903,8 @@ export class InMemoryTaskStore {
private executeSelectedPlatforms( private executeSelectedPlatforms(
task: TaskRecord, task: TaskRecord,
runs: PlatformRunRecord[] runs: PlatformRunRecord[],
trigger: StrategyAttemptTrigger = "system"
): void { ): void {
task.taskStage = "crawl"; task.taskStage = "crawl";
@ -552,6 +914,18 @@ export class InMemoryTaskStore {
run.status = "Blocked"; run.status = "Blocked";
run.reason = platformCatalogMap[run.platform].recoveryHint; run.reason = platformCatalogMap[run.platform].recoveryHint;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"login",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`,
{
layer: "L3",
errorType: "session_required"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.blocked`, `platform.${run.platform}.blocked`,
@ -565,6 +939,18 @@ export class InMemoryTaskStore {
run.status = "Blocked"; run.status = "Blocked";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"login",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要浏览器恢复。`,
{
layer: "L3",
errorType: "platform_blocked"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.blocked`, `platform.${run.platform}.blocked`,
@ -581,8 +967,27 @@ export class InMemoryTaskStore {
`platform.${run.platform}.running`, `platform.${run.platform}.running`,
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。` `${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
); );
this.recordStrategyAttempt(
task.taskId,
run.platform,
"detail",
"succeeded",
trigger,
`${platformCatalogMap[run.platform].label} 已通过 ${resolveDefaultLayer(run.platform, "detail")} 抓取商品详情。`
);
if (mockOutcome === "Failed") { if (mockOutcome === "Failed") {
this.recordStrategyAttempt(
task.taskId,
run.platform,
"reviews",
"failed",
trigger,
`${platformCatalogMap[run.platform].label} 评论抓取失败,可稍后定向重试。`,
{
errorType: "mock_failure"
}
);
run.status = "Failed"; run.status = "Failed";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
@ -594,6 +999,14 @@ export class InMemoryTaskStore {
continue; continue;
} }
this.recordStrategyAttempt(
task.taskId,
run.platform,
"reviews",
"succeeded",
trigger,
`${platformCatalogMap[run.platform].label} 已完成评论抓取与抽样。`
);
run.status = "Completed"; run.status = "Completed";
run.reason = undefined; run.reason = undefined;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
@ -635,6 +1048,13 @@ export class InMemoryTaskStore {
if (lastFingerprint === fingerprint) { if (lastFingerprint === fingerprint) {
this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。"); this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。");
this.pushAudit("report.unchanged", {
taskId: task.taskId,
message: "报告结果未变化,继续沿用当前版本。",
metadata: {
task_status: task.taskStatus
}
});
return false; return false;
} }
@ -645,7 +1065,28 @@ export class InMemoryTaskStore {
task.reportVersions = [...task.reportVersions, report.report_version]; task.reportVersions = [...task.reportVersions, report.report_version];
task.defaultReportVersion = report.report_version; task.defaultReportVersion = report.report_version;
task.latestSuccessfulReportVersion = 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.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; return true;
} }
@ -691,6 +1132,98 @@ export class InMemoryTaskStore {
return scenario.outcome; 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 { private pushEvent(task: TaskRecord, type: string, message: string): void {
const event: TaskEventRecord = { const event: TaskEventRecord = {
eventId: randomUUID(), eventId: randomUUID(),
@ -710,11 +1243,82 @@ export class InMemoryTaskStore {
return task; return task;
} }
private requireReadiness(platform: PlatformId): SessionReadinessRecord { private requirePlatformMetrics(
const readiness = this.readiness.get(platform); taskId: string
if (!readiness) { ): Record<PlatformId, PlatformRunMetricRecord> {
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.`); 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));
} }
} }

View File

@ -4,6 +4,8 @@ import {
type CandidateRecord, type CandidateRecord,
type PlatformId, type PlatformId,
type PlatformStatus, type PlatformStatus,
type SessionReadinessRecord,
type SessionStateRecord,
type TaskRecord, type TaskRecord,
type TaskStatus type TaskStatus
} from "@cross-ai/domain"; } from "@cross-ai/domain";
@ -22,11 +24,13 @@ import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./componen
import { TaskContextHeader } from "./components/TaskContextHeader"; import { TaskContextHeader } from "./components/TaskContextHeader";
import { TaskSpine } from "./components/TaskSpine"; import { TaskSpine } from "./components/TaskSpine";
import { import {
clearPlatformSession,
confirmTask, confirmTask,
createTask, createTask,
deleteTask, deleteTask,
getHistoryTasks, getHistoryTasks,
getPlatformReadiness, getPlatformReadiness,
getPlatformSession,
getTask, getTask,
getTaskCandidates, getTaskCandidates,
getTaskReport, 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 navigate = useNavigate();
const readinessQuery = useQuery({ const readinessQuery = useQuery({
queryKey: ["platform-readiness"], queryKey: ["platform-readiness"],
queryFn: getPlatformReadiness queryFn: getPlatformReadiness
}); });
const historyQuery = useQuery({
queryKey: ["history"],
queryFn: getHistoryTasks
});
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [perLinkLimit, setPerLinkLimit] = useState(100); const [perLinkLimit, setPerLinkLimit] = useState(100);
const [taskTotalLimit, setTaskTotalLimit] = useState(500); const [taskTotalLimit, setTaskTotalLimit] = useState(500);
@ -134,6 +191,7 @@ function NewTaskPage() {
navigate(`/tasks/${task.taskId}/confirm`); navigate(`/tasks/${task.taskId}/confirm`);
} }
}); });
const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4);
return ( return (
<Layout> <Layout>
@ -209,26 +267,72 @@ function NewTaskPage() {
status={platform.ready ? "Completed" : "SearchBlocked"} status={platform.ready ? "Completed" : "SearchBlocked"}
/> />
</div> </div>
<p>{platformCatalogMap[platform.platform].description}</p> <p className="readiness-card__caption">
<a {getSearchRequirementLabel(platform.searchRequirement)}
className="text-link" </p>
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`} <p>{getReadinessSummary(platform)}</p>
> <div className="readiness-card__meta">
<span className="inline-note inline-note--subtle">
</a> {formatTimestamp(platform.lastPreparedAt)}
</span>
<span className="inline-note inline-note--subtle">
{platform.expiresAt
? `有效至 ${formatTimestamp(platform.expiresAt)}`
: "当前无有效期中的会话"}
</span>
</div>
<div className="panel-actions">
<a
className="text-link"
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
>
</a>
</div>
</article> </article>
))} ))}
</div> </div>
</div> </div>
<div className="page-panel"> <div className="stack">
<p className="eyebrow">Scope Reminder</p> <div className="page-panel">
<h3>P0 </h3> <p className="eyebrow">Recent Tasks</p>
<ul className="list"> <h3></h3>
<li></li> <div className="stack stack--dense">
<li></li> {recentTasks.length > 0 ? (
<li></li> recentTasks.map((task) => (
</ul> <a
key={task.taskId}
className="mini-task-link"
href={getTaskDestination(
task.taskId,
task.taskStatus,
task.hasReport,
task.defaultReportVersion
)}
>
<div className="mini-task-link__topline">
<strong>{task.query}</strong>
<TaskStatusPill status={task.taskStatus} />
</div>
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
</a>
))
) : (
<div className="empty-state"></div>
)}
</div>
</div>
<div className="page-panel">
<p className="eyebrow">Scope Reminder</p>
<h3>P0 </h3>
<ul className="list">
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div> </div>
</section> </section>
</Layout> </Layout>
@ -983,34 +1087,87 @@ export function HistoryPage() {
); );
} }
function SessionPreparePage() { export function SessionPreparePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { platform = "tmall" } = useParams(); const { platform = "tmall" } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/tasks/new"; const from = searchParams.get("from") ?? "/tasks/new";
const platformId = platform as PlatformId;
const sessionQuery = useQuery({
queryKey: ["session", platformId],
queryFn: () => getPlatformSession(platformId)
});
const prepareMutation = useMutation({ const prepareMutation = useMutation({
mutationFn: () => preparePlatform(platform as PlatformId), mutationFn: () => preparePlatform(platformId),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }); await Promise.all([
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
]);
navigate(from); 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 ( return (
<Layout> <Layout>
<section className="page-panel session-panel"> <section className="page-panel session-panel">
<p className="eyebrow">Session Console</p> <p className="eyebrow">Session Console</p>
<h2>{platformCatalogMap[platform as PlatformId].label} </h2> <h2>{platformCatalogMap[platformId].label} </h2>
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p> <p>{platformCatalogMap[platformId].recoveryHint}</p>
<div className="session-placeholder"> <div className="session-placeholder">
<div className="session-placeholder__viewport">Remote Browser Viewport</div> <div className="session-placeholder__viewport">Remote Browser Viewport</div>
<div className="session-placeholder__sidebar"> <div className="session-placeholder__sidebar">
<strong>prepare</strong> <strong>prepare</strong>
<p></p> <p></p>
<button className="primary-button" onClick={() => prepareMutation.mutate()} type="button"> <div className="session-details">
<div className="session-details__row">
</button> <span></span>
<PlatformStatusPill status={session?.ready ? "Completed" : "SearchBlocked"} />
</div>
<div className="session-details__row">
<span></span>
<strong>{getSearchRequirementLabel(platformCatalogMap[platformId].searchRequirement)}</strong>
</div>
<div className="session-details__row">
<span></span>
<strong>{session?.encryptedSnapshotAvailable ? "已加密保存" : "尚未生成"}</strong>
</div>
<div className="session-details__row">
<span></span>
<strong>{session?.expiresAt ? formatTimestamp(session.expiresAt) : "暂无"}</strong>
</div>
<p>{session ? getSessionSnapshotSummary(session) : "正在读取当前会话状态..."}</p>
<p className="session-return-target">{from}</p>
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={prepareMutation.isPending}
onClick={() => prepareMutation.mutate()}
type="button"
>
</button>
<button
className="ghost-button"
disabled={clearMutation.isPending || !session?.encryptedSnapshotAvailable}
onClick={() => clearMutation.mutate()}
type="button"
>
</button>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -6,11 +6,13 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({ vi.mock("./lib/api", () => ({
clearPlatformSession: vi.fn(),
confirmTask: vi.fn(), confirmTask: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
getHistoryTasks: vi.fn(), getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(), getPlatformReadiness: vi.fn(),
getPlatformSession: vi.fn(),
getTask: vi.fn(), getTask: vi.fn(),
getTaskCandidates: vi.fn(), getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(), getTaskReport: vi.fn(),

View File

@ -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(
<QueryClientProvider client={queryClient}>
{initialEntries ? (
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
) : (
<MemoryRouter>{node}</MemoryRouter>
)}
</QueryClientProvider>
);
}
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(<NewTaskPage />);
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(
<Routes>
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
</Routes>,
["/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");
});
});
});

View File

@ -220,7 +220,8 @@ a {
.candidate-card, .candidate-card,
.metric-card, .metric-card,
.insight-card, .insight-card,
.evidence-card { .evidence-card,
.mini-task-link {
border: 1px solid rgba(31, 42, 48, 0.08); border: 1px solid rgba(31, 42, 48, 0.08);
border-radius: 18px; border-radius: 18px;
background: var(--bg-elevated); background: var(--bg-elevated);
@ -231,7 +232,8 @@ a {
.history-card, .history-card,
.metric-card, .metric-card,
.insight-card, .insight-card,
.evidence-card { .evidence-card,
.mini-task-link {
padding: 16px; padding: 16px;
} }
@ -256,6 +258,37 @@ a {
flex-wrap: wrap; 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 { .task-context-header {
padding: 20px 0 0; padding: 20px 0 0;
} }
@ -424,6 +457,12 @@ a {
background: rgba(20, 108, 110, 0.08); 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 { .metric-card {
display: grid; display: grid;
gap: 6px; gap: 6px;
@ -568,6 +607,31 @@ a {
color: var(--text-secondary); 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 { .list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;

View File

@ -4,6 +4,27 @@ export type PlatformId = (typeof platforms)[number];
export const searchRequirements = ["none", "recommended", "required"] as const; export const searchRequirements = ["none", "recommended", "required"] as const;
export type SearchRequirement = (typeof searchRequirements)[number]; 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 = [ export const taskStatuses = [
"Draft", "Draft",
"Searching", "Searching",
@ -64,3 +85,18 @@ export type SampleFlag = (typeof sampleFlags)[number];
export const evidenceSourceTypes = ["product", "review"] as const; export const evidenceSourceTypes = ["product", "review"] as const;
export type EvidenceSourceType = (typeof evidenceSourceTypes)[number]; 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];

View File

@ -1,7 +1,15 @@
import type { import type {
AuditAction,
PlatformCapability,
PlatformId, PlatformId,
PlatformStatus, PlatformStatus,
SearchRequirement, SearchRequirement,
ReportableTaskStatus,
RetentionAction,
SessionStatus,
StrategyAttemptOutcome,
StrategyAttemptTrigger,
StrategyLayer,
TaskStage, TaskStage,
TaskStatus TaskStatus
} from "./enums"; } from "./enums";
@ -46,8 +54,120 @@ export interface TaskEventRecord {
export interface SessionReadinessRecord { export interface SessionReadinessRecord {
platform: PlatformId; platform: PlatformId;
ready: boolean; ready: boolean;
status: SessionStatus;
searchRequirement: SearchRequirement; searchRequirement: SearchRequirement;
reason: string;
lastPreparedAt?: string | undefined; 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<string, AuditMetadataValue>;
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 { export interface TaskRecord {