feat: 补齐会话中心 v1 与任务创建准备流
This commit is contained in:
parent
29cea8b0aa
commit
145f958663
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,18 +267,63 @@ 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">
|
||||||
|
搜索要求:{getSearchRequirementLabel(platform.searchRequirement)}
|
||||||
|
</p>
|
||||||
|
<p>{getReadinessSummary(platform)}</p>
|
||||||
|
<div className="readiness-card__meta">
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
最近准备:{formatTimestamp(platform.lastPreparedAt)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{platform.expiresAt
|
||||||
|
? `有效至 ${formatTimestamp(platform.expiresAt)}`
|
||||||
|
: "当前无有效期中的会话"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="panel-actions">
|
||||||
<a
|
<a
|
||||||
className="text-link"
|
className="text-link"
|
||||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||||
>
|
>
|
||||||
进入会话准备
|
进入会话准备
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="stack">
|
||||||
|
<div className="page-panel">
|
||||||
|
<p className="eyebrow">Recent Tasks</p>
|
||||||
|
<h3>最近任务捷径</h3>
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
{recentTasks.length > 0 ? (
|
||||||
|
recentTasks.map((task) => (
|
||||||
|
<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">
|
<div className="page-panel">
|
||||||
<p className="eyebrow">Scope Reminder</p>
|
<p className="eyebrow">Scope Reminder</p>
|
||||||
<h3>P0 当前不做什么</h3>
|
<h3>P0 当前不做什么</h3>
|
||||||
@ -230,6 +333,7 @@ function NewTaskPage() {
|
|||||||
<li>当前工作台只覆盖天猫、京东。</li>
|
<li>当前工作台只覆盖天猫、京东。</li>
|
||||||
</ul>
|
</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">
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={clearMutation.isPending || !session?.encryptedSnapshotAvailable}
|
||||||
|
onClick={() => clearMutation.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
清理当前会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
158
apps/web/src/NewTaskPage.test.tsx
Normal file
158
apps/web/src/NewTaskPage.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user