feat: 补齐会话中心 v1 与任务创建准备流
This commit is contained in:
parent
29cea8b0aa
commit
145f958663
@ -49,6 +49,46 @@ describe("API server", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exposes session state with a 24-hour expiry window after preparation", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const prepareResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/jd/prepare"
|
||||
});
|
||||
|
||||
expect(prepareResponse.statusCode).toBe(200);
|
||||
const preparedPayload = prepareResponse.json();
|
||||
expect(preparedPayload).toMatchObject({
|
||||
platform: "jd",
|
||||
session_ready: true,
|
||||
status: "ready",
|
||||
encrypted_snapshot_available: true
|
||||
});
|
||||
expect(
|
||||
Date.parse(preparedPayload.expires_at) - Date.parse(preparedPayload.last_prepared_at)
|
||||
).toBe(24 * 60 * 60 * 1000);
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sessions/jd"
|
||||
});
|
||||
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json().session).toMatchObject({
|
||||
platform: "jd",
|
||||
ready: true,
|
||||
status: "ready",
|
||||
scope: "workspace",
|
||||
ttlHours: 24,
|
||||
encryptedSnapshotAvailable: true,
|
||||
cipherLabel: "mock-aes-gcm-v1"
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
@ -82,6 +122,102 @@ describe("API server", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("records strategy attempts and observability metrics for platform search", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createdTask = await createTask(app, "iPhone 15 Pro");
|
||||
|
||||
const attemptsResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/strategy-attempts`
|
||||
});
|
||||
|
||||
expect(attemptsResponse.statusCode).toBe(200);
|
||||
expect(attemptsResponse.json().attempts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
platform: "tmall",
|
||||
capability: "search",
|
||||
outcome: "succeeded",
|
||||
layer: "L1"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
capability: "search",
|
||||
outcome: "blocked",
|
||||
layer: "L0",
|
||||
errorType: "session_required"
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
const overviewResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/observability/overview"
|
||||
});
|
||||
|
||||
expect(overviewResponse.statusCode).toBe(200);
|
||||
expect(overviewResponse.json().overview.strategyAttempts).toMatchObject({
|
||||
total: 2,
|
||||
searchSuccessRate: 50
|
||||
});
|
||||
expect(
|
||||
overviewResponse
|
||||
.json()
|
||||
.overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd")
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
searchDurationMs: expect.any(Number)
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("clears a prepared session and updates the readiness summary", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
await preparePlatform(app, "jd");
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/sessions/jd"
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(204);
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/sessions/jd"
|
||||
});
|
||||
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json().session).toMatchObject({
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
status: "missing",
|
||||
encryptedSnapshotAvailable: false
|
||||
});
|
||||
|
||||
const readinessResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
|
||||
expect(
|
||||
readinessResponse
|
||||
.json()
|
||||
.platforms.find((platform: { platform: string }) => platform.platform === "jd")
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
status: "missing"
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("supports NoSelection terminal state when user confirms nothing", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
@ -181,6 +317,62 @@ describe("API server", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("records recovery audit entries and retry metrics for recovered platforms", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createdTask = await createTask(app, "iPhone 15 Pro");
|
||||
|
||||
await preparePlatform(app, "jd");
|
||||
const retryResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
|
||||
});
|
||||
|
||||
expect(retryResponse.statusCode).toBe(200);
|
||||
|
||||
const auditResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/audit`
|
||||
});
|
||||
|
||||
expect(auditResponse.statusCode).toBe(200);
|
||||
expect(auditResponse.json().audit).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
action: "platform.retry_started",
|
||||
platform: "jd"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
action: "task.recovery_completed",
|
||||
platform: "jd"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
action: "platform.retry_completed",
|
||||
platform: "jd"
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
const overviewResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/observability/overview"
|
||||
});
|
||||
|
||||
expect(
|
||||
overviewResponse
|
||||
.json()
|
||||
.overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd")
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
retryCount: 1,
|
||||
recoveryCount: 1
|
||||
});
|
||||
expect(overviewResponse.json().overview.audits.recoveryActions).toBe(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("publishes a new report version when a blocked platform recovers successfully", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
@ -340,6 +532,16 @@ describe("API server", () => {
|
||||
.tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId)
|
||||
).toBe(false);
|
||||
|
||||
const overviewResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/observability/overview"
|
||||
});
|
||||
expect(overviewResponse.json().overview.retention).toMatchObject({
|
||||
taskDeletes: 1,
|
||||
deletedReports: 1
|
||||
});
|
||||
expect(overviewResponse.json().overview.audits.deleteActions).toBe(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,18 +23,50 @@ export function createServer() {
|
||||
platforms: store.getPlatformReadiness()
|
||||
}));
|
||||
|
||||
app.get("/api/sessions", async () => ({
|
||||
sessions: store.listSessions()
|
||||
}));
|
||||
|
||||
app.get<{
|
||||
Params: { platform: PlatformId };
|
||||
}>("/api/sessions/:platform", async (request, reply) => {
|
||||
try {
|
||||
const session = store.getSession(request.params.platform);
|
||||
return { session };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { message: "Session not found." };
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { platform: PlatformId };
|
||||
}>("/api/platforms/:platform/prepare", async (request, reply) => {
|
||||
const readiness = store.preparePlatform(request.params.platform);
|
||||
const session = store.preparePlatform(request.params.platform);
|
||||
reply.code(200);
|
||||
return {
|
||||
platform: readiness.platform,
|
||||
session_ready: readiness.ready,
|
||||
last_prepared_at: readiness.lastPreparedAt
|
||||
platform: session.platform,
|
||||
session_ready: session.ready,
|
||||
status: session.status,
|
||||
last_prepared_at: session.lastPreparedAt,
|
||||
expires_at: session.expiresAt,
|
||||
encrypted_snapshot_available: session.encryptedSnapshotAvailable
|
||||
};
|
||||
});
|
||||
|
||||
app.delete<{
|
||||
Params: { platform: PlatformId };
|
||||
}>("/api/sessions/:platform", async (request, reply) => {
|
||||
try {
|
||||
store.clearPlatformSession(request.params.platform);
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { message: "Session not found." };
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: CreateTaskInput;
|
||||
}>("/api/tasks", async (request, reply) => {
|
||||
@ -78,6 +110,30 @@ export function createServer() {
|
||||
return { candidates };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/strategy-attempts", async (request, reply) => {
|
||||
const attempts = store.getTaskStrategyAttempts(request.params.taskId);
|
||||
if (!attempts) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
|
||||
return { attempts };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/audit", async (request, reply) => {
|
||||
const audit = store.getTaskAuditLogs(request.params.taskId);
|
||||
if (!audit) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
|
||||
return { audit };
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { taskId: string };
|
||||
Body: ConfirmTaskPayload;
|
||||
@ -120,6 +176,10 @@ export function createServer() {
|
||||
tasks: store.listHistory()
|
||||
}));
|
||||
|
||||
app.get("/api/observability/overview", async () => ({
|
||||
overview: store.getObservabilityOverview()
|
||||
}));
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/events", async (request, reply) => {
|
||||
|
||||
@ -1,17 +1,32 @@
|
||||
import {
|
||||
auditActions,
|
||||
deriveTaskStatusFromConfirmedPlatforms,
|
||||
mapPlatformStatusToExecutionStatus,
|
||||
platformCatalog,
|
||||
platformCatalogMap,
|
||||
platforms,
|
||||
strategyAttemptOutcomes,
|
||||
strategyLayers,
|
||||
type AuditAction,
|
||||
type AuditLogRecord,
|
||||
type CandidateRecord,
|
||||
type ConfirmTaskPayload,
|
||||
type CreateTaskInput,
|
||||
type HistoryTaskRecord,
|
||||
type ObservabilityOverview,
|
||||
type PlatformId,
|
||||
type PlatformCapability,
|
||||
type PlatformRunMetricRecord,
|
||||
type PlatformRunRecord,
|
||||
type PlatformStatus,
|
||||
type ReportMetricRecord,
|
||||
type RetentionMetricRecord,
|
||||
type SessionReadinessRecord,
|
||||
type SessionStateRecord,
|
||||
type StrategyAttemptOutcome,
|
||||
type StrategyAttemptRecord,
|
||||
type StrategyAttemptTrigger,
|
||||
type StrategyLayer,
|
||||
type TaskEventRecord,
|
||||
type TaskRecord
|
||||
} from "@cross-ai/domain";
|
||||
@ -31,6 +46,135 @@ function createPlatformCandidatesRecord(): Record<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 = {
|
||||
outcome: Extract<PlatformStatus, "Blocked" | "Failed">;
|
||||
mode: "once" | "always";
|
||||
@ -78,39 +222,63 @@ export class InMemoryTaskStore {
|
||||
private readonly tasks = new Map<string, TaskRecord>();
|
||||
private readonly reports = new Map<string, ReportSnapshot[]>();
|
||||
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<
|
||||
string,
|
||||
Partial<Record<PlatformId, MockExecutionScenario>>
|
||||
>();
|
||||
|
||||
constructor() {
|
||||
this.readiness.set("tmall", {
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
searchRequirement: "recommended",
|
||||
lastPreparedAt: nowIso()
|
||||
});
|
||||
this.readiness.set("jd", {
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
searchRequirement: "required"
|
||||
});
|
||||
const timestamp = nowIso();
|
||||
this.readiness.set("tmall", createPreparedSession("tmall", timestamp));
|
||||
this.readiness.set("jd", createMissingSession("jd"));
|
||||
}
|
||||
|
||||
getPlatformReadiness(): SessionReadinessRecord[] {
|
||||
return platforms.map((platform) => this.requireReadiness(platform));
|
||||
}
|
||||
|
||||
preparePlatform(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.requireReadiness(platform);
|
||||
const next: SessionReadinessRecord = {
|
||||
...readiness,
|
||||
ready: true,
|
||||
lastPreparedAt: nowIso()
|
||||
};
|
||||
listSessions(): SessionStateRecord[] {
|
||||
return platforms.map((platform) => this.getSession(platform));
|
||||
}
|
||||
|
||||
getSession(platform: PlatformId): SessionStateRecord {
|
||||
return this.toSessionRecord(this.requireSession(platform));
|
||||
}
|
||||
|
||||
preparePlatform(platform: PlatformId): SessionStateRecord {
|
||||
const timestamp = nowIso();
|
||||
const next = createPreparedSession(platform, timestamp);
|
||||
this.readiness.set(platform, next);
|
||||
return next;
|
||||
this.pushAudit("session.prepared", {
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 会话已标记为预热完成。`,
|
||||
metadata: {
|
||||
search_requirement: next.searchRequirement,
|
||||
expires_at: next.expiresAt ?? null
|
||||
}
|
||||
});
|
||||
return this.toSessionRecord(next);
|
||||
}
|
||||
|
||||
clearPlatformSession(platform: PlatformId): void {
|
||||
const cleared = createMissingSession(platform);
|
||||
this.readiness.set(platform, cleared);
|
||||
this.pushAudit("session.cleared", {
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 会话已清理。`,
|
||||
metadata: {
|
||||
search_requirement: cleared.searchRequirement
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createTask(input: CreateTaskInput): TaskRecord {
|
||||
@ -141,6 +309,9 @@ export class InMemoryTaskStore {
|
||||
reportVersions: []
|
||||
};
|
||||
|
||||
this.strategyAttempts.set(taskId, []);
|
||||
this.platformRunMetrics.set(taskId, createPlatformRunMetricsRecord(taskId, timestamp));
|
||||
|
||||
if (Object.keys(executionScenarios).length > 0) {
|
||||
this.executionScenarios.set(taskId, executionScenarios);
|
||||
}
|
||||
@ -184,6 +355,93 @@ export class InMemoryTaskStore {
|
||||
return this.tasks.get(taskId)?.platformCandidates;
|
||||
}
|
||||
|
||||
getTaskStrategyAttempts(taskId: string): StrategyAttemptRecord[] | undefined {
|
||||
if (!this.tasks.has(taskId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [...(this.strategyAttempts.get(taskId) ?? [])];
|
||||
}
|
||||
|
||||
getTaskAuditLogs(taskId: string): AuditLogRecord[] | undefined {
|
||||
if (!this.tasks.has(taskId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.auditLogs.filter((entry) => entry.taskId === taskId);
|
||||
}
|
||||
|
||||
getObservabilityOverview(): ObservabilityOverview {
|
||||
const strategyAttempts = Array.from(this.strategyAttempts.values()).flat();
|
||||
const totalAttempts = strategyAttempts.length;
|
||||
const searchAttempts = strategyAttempts.filter((attempt) => attempt.capability === "search");
|
||||
const successfulSearchAttempts = searchAttempts.filter(
|
||||
(attempt) => attempt.outcome === "succeeded"
|
||||
);
|
||||
const platformRuns = Array.from(this.platformRunMetrics.values()).flatMap((record) =>
|
||||
platforms.map((platform) => record[platform])
|
||||
);
|
||||
|
||||
return {
|
||||
strategyAttempts: {
|
||||
total: totalAttempts,
|
||||
searchSuccessRate:
|
||||
searchAttempts.length > 0
|
||||
? Number(
|
||||
((successfulSearchAttempts.length / searchAttempts.length) * 100).toFixed(2)
|
||||
)
|
||||
: 0,
|
||||
browserFallbackShare:
|
||||
totalAttempts > 0
|
||||
? Number(
|
||||
(
|
||||
(strategyAttempts.filter((attempt) => attempt.layer === "L3").length /
|
||||
totalAttempts) *
|
||||
100
|
||||
).toFixed(2)
|
||||
)
|
||||
: 0,
|
||||
byLayer: strategyLayers.map((layer) => ({
|
||||
layer,
|
||||
count: strategyAttempts.filter((attempt) => attempt.layer === layer).length
|
||||
})),
|
||||
byOutcome: strategyAttemptOutcomes.map((outcome) => ({
|
||||
outcome,
|
||||
count: strategyAttempts.filter((attempt) => attempt.outcome === outcome).length
|
||||
}))
|
||||
},
|
||||
platformRuns,
|
||||
reports: {
|
||||
published: this.reportMetrics.length,
|
||||
unchanged: this.auditLogs.filter((entry) => entry.action === "report.unchanged").length,
|
||||
sampleInsufficient: this.reportMetrics.filter((metric) => metric.sampleInsufficient).length,
|
||||
partialFailures: this.reportMetrics.filter((metric) => metric.partialPlatformFailure).length
|
||||
},
|
||||
retention: {
|
||||
taskDeletes: this.retentionMetrics.filter((metric) => metric.action === "task_deleted")
|
||||
.length,
|
||||
cleanupRuns: this.retentionMetrics.filter((metric) => metric.action === "retention_cleanup")
|
||||
.length,
|
||||
deletedReports: this.retentionMetrics.reduce(
|
||||
(sum, metric) => sum + metric.deletedReportCount,
|
||||
0
|
||||
),
|
||||
residualArtifacts: this.retentionMetrics.reduce(
|
||||
(sum, metric) => sum + metric.residualArtifactCount,
|
||||
0
|
||||
)
|
||||
},
|
||||
audits: {
|
||||
total: this.auditLogs.length,
|
||||
recoveryActions: this.auditLogs.filter(
|
||||
(entry) =>
|
||||
entry.action === "session.prepared" || entry.action === "task.recovery_completed"
|
||||
).length,
|
||||
deleteActions: this.auditLogs.filter((entry) => entry.action === "task.deleted").length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord {
|
||||
const task = this.requireTask(taskId);
|
||||
const selectionMap = new Map(
|
||||
@ -253,12 +511,27 @@ export class InMemoryTaskStore {
|
||||
return task;
|
||||
}
|
||||
|
||||
this.incrementRetryCount(task.taskId, platform);
|
||||
this.incrementRecoveryCount(task.taskId, platform);
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${platform}.retry_started`,
|
||||
`${platformCatalogMap[platform].label} 正在重新获取候选结果。`
|
||||
);
|
||||
this.runSearchForPlatform(task, run);
|
||||
this.pushAudit("platform.retry_started", {
|
||||
taskId,
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 已发起候选重试。`,
|
||||
metadata: {
|
||||
previous_status: "SearchBlocked"
|
||||
}
|
||||
});
|
||||
this.pushAudit("task.recovery_completed", {
|
||||
taskId,
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新搜索。`
|
||||
});
|
||||
this.runSearchForPlatform(task, run, "recovery");
|
||||
const recoveredCandidateCount = task.platformCandidates[platform].length;
|
||||
task.taskStage = "confirmation";
|
||||
task.taskStatus = "AwaitingConfirmation";
|
||||
@ -270,6 +543,18 @@ export class InMemoryTaskStore {
|
||||
? `${platformCatalogMap[platform].label} 已恢复候选确认。`
|
||||
: `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。`
|
||||
);
|
||||
this.pushAudit("platform.retry_completed", {
|
||||
taskId,
|
||||
platform,
|
||||
message:
|
||||
recoveredCandidateCount > 0
|
||||
? `${platformCatalogMap[platform].label} 候选重试已恢复。`
|
||||
: `${platformCatalogMap[platform].label} 候选重试后仍无结果。`,
|
||||
metadata: {
|
||||
candidate_count: recoveredCandidateCount,
|
||||
next_status: run.status
|
||||
}
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
@ -282,6 +567,16 @@ export class InMemoryTaskStore {
|
||||
return task;
|
||||
}
|
||||
|
||||
const previousStatus = run.status;
|
||||
this.incrementRetryCount(task.taskId, platform);
|
||||
if (previousStatus === "Blocked") {
|
||||
this.incrementRecoveryCount(task.taskId, platform);
|
||||
this.pushAudit("task.recovery_completed", {
|
||||
taskId,
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新执行。`
|
||||
});
|
||||
}
|
||||
task.taskStatus = "Running";
|
||||
task.taskStage = "session_check";
|
||||
this.pushEvent(
|
||||
@ -289,11 +584,28 @@ export class InMemoryTaskStore {
|
||||
`platform.${platform}.retry_started`,
|
||||
`${platformCatalogMap[platform].label} 正在执行定向重试。`
|
||||
);
|
||||
this.pushAudit("platform.retry_started", {
|
||||
taskId,
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 已发起平台级重试。`,
|
||||
metadata: {
|
||||
previous_status: previousStatus
|
||||
}
|
||||
});
|
||||
|
||||
this.executeSelectedPlatforms(task, [run]);
|
||||
this.executeSelectedPlatforms(task, [run], previousStatus === "Blocked" ? "recovery" : "retry");
|
||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||
task.updatedAt = nowIso();
|
||||
this.publishReportIfNeeded(task);
|
||||
this.pushAudit("platform.retry_completed", {
|
||||
taskId,
|
||||
platform,
|
||||
message: `${platformCatalogMap[platform].label} 平台级重试已结束。`,
|
||||
metadata: {
|
||||
next_status: run.status,
|
||||
task_status: task.taskStatus
|
||||
}
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
@ -312,10 +624,29 @@ export class InMemoryTaskStore {
|
||||
}
|
||||
|
||||
deleteTask(taskId: string): void {
|
||||
this.requireTask(taskId);
|
||||
const task = this.requireTask(taskId);
|
||||
const deletedReportCount = this.reports.get(taskId)?.length ?? 0;
|
||||
this.retentionMetrics.push({
|
||||
metricId: randomUUID(),
|
||||
action: "task_deleted",
|
||||
taskId,
|
||||
deletedReportCount,
|
||||
deletedArtifactCount: 0,
|
||||
residualArtifactCount: 0,
|
||||
recordedAt: nowIso()
|
||||
});
|
||||
this.pushAudit("task.deleted", {
|
||||
taskId,
|
||||
message: `任务“${task.query}”及其关联报告已删除。`,
|
||||
metadata: {
|
||||
deleted_report_count: deletedReportCount
|
||||
}
|
||||
});
|
||||
this.tasks.delete(taskId);
|
||||
this.reports.delete(taskId);
|
||||
this.reportFingerprints.delete(taskId);
|
||||
this.strategyAttempts.delete(taskId);
|
||||
this.platformRunMetrics.delete(taskId);
|
||||
this.executionScenarios.delete(taskId);
|
||||
}
|
||||
|
||||
@ -504,13 +835,28 @@ export class InMemoryTaskStore {
|
||||
});
|
||||
}
|
||||
|
||||
private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void {
|
||||
private runSearchForPlatform(
|
||||
task: TaskRecord,
|
||||
run: PlatformRunRecord,
|
||||
trigger: StrategyAttemptTrigger = "system"
|
||||
): void {
|
||||
const readiness = this.requireReadiness(run.platform);
|
||||
if (readiness.searchRequirement === "required" && !readiness.ready) {
|
||||
run.status = "SearchBlocked";
|
||||
run.reason = platformCatalogMap[run.platform].recoveryHint;
|
||||
run.candidateCount = 0;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"search",
|
||||
"blocked",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 搜索前检查缺少有效会话。`,
|
||||
{
|
||||
errorType: "session_required"
|
||||
}
|
||||
);
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.search_blocked`,
|
||||
@ -531,6 +877,21 @@ export class InMemoryTaskStore {
|
||||
? undefined
|
||||
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"search",
|
||||
candidates.length > 0 ? "succeeded" : "no_result",
|
||||
trigger,
|
||||
candidates.length > 0
|
||||
? `${platformCatalogMap[run.platform].label} 已返回候选列表。`
|
||||
: `${platformCatalogMap[run.platform].label} 本轮搜索没有候选结果。`,
|
||||
candidates.length > 0
|
||||
? undefined
|
||||
: {
|
||||
errorType: "no_candidates"
|
||||
}
|
||||
);
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.searched`,
|
||||
@ -542,7 +903,8 @@ export class InMemoryTaskStore {
|
||||
|
||||
private executeSelectedPlatforms(
|
||||
task: TaskRecord,
|
||||
runs: PlatformRunRecord[]
|
||||
runs: PlatformRunRecord[],
|
||||
trigger: StrategyAttemptTrigger = "system"
|
||||
): void {
|
||||
task.taskStage = "crawl";
|
||||
|
||||
@ -552,6 +914,18 @@ export class InMemoryTaskStore {
|
||||
run.status = "Blocked";
|
||||
run.reason = platformCatalogMap[run.platform].recoveryHint;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"login",
|
||||
"blocked",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`,
|
||||
{
|
||||
layer: "L3",
|
||||
errorType: "session_required"
|
||||
}
|
||||
);
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.blocked`,
|
||||
@ -565,6 +939,18 @@ export class InMemoryTaskStore {
|
||||
run.status = "Blocked";
|
||||
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"login",
|
||||
"blocked",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要浏览器恢复。`,
|
||||
{
|
||||
layer: "L3",
|
||||
errorType: "platform_blocked"
|
||||
}
|
||||
);
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.blocked`,
|
||||
@ -581,8 +967,27 @@ export class InMemoryTaskStore {
|
||||
`platform.${run.platform}.running`,
|
||||
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
|
||||
);
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"detail",
|
||||
"succeeded",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 已通过 ${resolveDefaultLayer(run.platform, "detail")} 抓取商品详情。`
|
||||
);
|
||||
|
||||
if (mockOutcome === "Failed") {
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"reviews",
|
||||
"failed",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 评论抓取失败,可稍后定向重试。`,
|
||||
{
|
||||
errorType: "mock_failure"
|
||||
}
|
||||
);
|
||||
run.status = "Failed";
|
||||
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
@ -594,6 +999,14 @@ export class InMemoryTaskStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.recordStrategyAttempt(
|
||||
task.taskId,
|
||||
run.platform,
|
||||
"reviews",
|
||||
"succeeded",
|
||||
trigger,
|
||||
`${platformCatalogMap[run.platform].label} 已完成评论抓取与抽样。`
|
||||
);
|
||||
run.status = "Completed";
|
||||
run.reason = undefined;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
@ -635,6 +1048,13 @@ export class InMemoryTaskStore {
|
||||
|
||||
if (lastFingerprint === fingerprint) {
|
||||
this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。");
|
||||
this.pushAudit("report.unchanged", {
|
||||
taskId: task.taskId,
|
||||
message: "报告结果未变化,继续沿用当前版本。",
|
||||
metadata: {
|
||||
task_status: task.taskStatus
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -645,7 +1065,28 @@ export class InMemoryTaskStore {
|
||||
task.reportVersions = [...task.reportVersions, report.report_version];
|
||||
task.defaultReportVersion = report.report_version;
|
||||
task.latestSuccessfulReportVersion = report.report_version;
|
||||
this.reportMetrics.push({
|
||||
taskId: task.taskId,
|
||||
reportVersion: report.report_version,
|
||||
taskStatus: task.taskStatus,
|
||||
selectedLinkCount: report.product_snapshot.selected_link_count,
|
||||
reviewSampleCount: report.product_snapshot.review_sample_count,
|
||||
blockedPlatforms: report.quality_flags.blocked_platforms,
|
||||
failedPlatforms: report.quality_flags.failed_platforms,
|
||||
sampleInsufficient: report.quality_flags.sample_insufficient,
|
||||
partialPlatformFailure: report.quality_flags.partial_platform_failure,
|
||||
generatedAt: report.generated_at
|
||||
});
|
||||
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
|
||||
this.pushAudit("report.published", {
|
||||
taskId: task.taskId,
|
||||
message: `报告 v${report.report_version} 已生成。`,
|
||||
metadata: {
|
||||
report_version: report.report_version,
|
||||
task_status: task.taskStatus,
|
||||
review_sample_count: report.product_snapshot.review_sample_count
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -691,6 +1132,98 @@ export class InMemoryTaskStore {
|
||||
return scenario.outcome;
|
||||
}
|
||||
|
||||
private recordStrategyAttempt(
|
||||
taskId: string,
|
||||
platform: PlatformId,
|
||||
capability: PlatformCapability,
|
||||
outcome: StrategyAttemptOutcome,
|
||||
trigger: StrategyAttemptTrigger,
|
||||
detail: string,
|
||||
options?: {
|
||||
layer?: StrategyLayer;
|
||||
errorType?: string;
|
||||
}
|
||||
): StrategyAttemptRecord {
|
||||
const durationMs = getMockDurationMs(platform, capability, outcome);
|
||||
const finishedAt = nowIso();
|
||||
const startedAt = new Date(Date.parse(finishedAt) - durationMs).toISOString();
|
||||
const attempt: StrategyAttemptRecord = {
|
||||
attemptId: randomUUID(),
|
||||
taskId,
|
||||
platform,
|
||||
capability,
|
||||
layer: options?.layer ?? resolveDefaultLayer(platform, capability),
|
||||
outcome,
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
errorType: options?.errorType,
|
||||
detail
|
||||
};
|
||||
const attempts = this.strategyAttempts.get(taskId) ?? [];
|
||||
attempts.push(attempt);
|
||||
this.strategyAttempts.set(taskId, attempts);
|
||||
this.addCapabilityDuration(taskId, platform, capability, durationMs);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private addCapabilityDuration(
|
||||
taskId: string,
|
||||
platform: PlatformId,
|
||||
capability: PlatformCapability,
|
||||
durationMs: number
|
||||
): void {
|
||||
const metrics = this.requirePlatformMetrics(taskId);
|
||||
const target = metrics[platform];
|
||||
|
||||
if (capability === "search") {
|
||||
target.searchDurationMs += durationMs;
|
||||
} else if (capability === "detail") {
|
||||
target.detailDurationMs += durationMs;
|
||||
} else if (capability === "reviews") {
|
||||
target.reviewsDurationMs += durationMs;
|
||||
}
|
||||
|
||||
target.lastUpdatedAt = nowIso();
|
||||
}
|
||||
|
||||
private incrementRetryCount(taskId: string, platform: PlatformId): void {
|
||||
const metrics = this.requirePlatformMetrics(taskId);
|
||||
metrics[platform].retryCount += 1;
|
||||
metrics[platform].lastUpdatedAt = nowIso();
|
||||
}
|
||||
|
||||
private incrementRecoveryCount(taskId: string, platform: PlatformId): void {
|
||||
const metrics = this.requirePlatformMetrics(taskId);
|
||||
metrics[platform].recoveryCount += 1;
|
||||
metrics[platform].lastUpdatedAt = nowIso();
|
||||
}
|
||||
|
||||
private pushAudit(
|
||||
action: AuditAction,
|
||||
input: {
|
||||
message: string;
|
||||
taskId?: string;
|
||||
platform?: PlatformId;
|
||||
metadata?: AuditLogRecord["metadata"];
|
||||
}
|
||||
): void {
|
||||
if (!auditActions.includes(action)) {
|
||||
throw new Error(`Unsupported audit action ${action}.`);
|
||||
}
|
||||
|
||||
this.auditLogs.push({
|
||||
auditId: randomUUID(),
|
||||
taskId: input.taskId,
|
||||
platform: input.platform,
|
||||
action,
|
||||
message: input.message,
|
||||
createdAt: nowIso(),
|
||||
metadata: input.metadata
|
||||
});
|
||||
}
|
||||
|
||||
private pushEvent(task: TaskRecord, type: string, message: string): void {
|
||||
const event: TaskEventRecord = {
|
||||
eventId: randomUUID(),
|
||||
@ -710,11 +1243,82 @@ export class InMemoryTaskStore {
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireReadiness(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.readiness.get(platform);
|
||||
if (!readiness) {
|
||||
private requirePlatformMetrics(
|
||||
taskId: string
|
||||
): Record<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.`);
|
||||
}
|
||||
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 PlatformId,
|
||||
type PlatformStatus,
|
||||
type SessionReadinessRecord,
|
||||
type SessionStateRecord,
|
||||
type TaskRecord,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
@ -22,11 +24,13 @@ import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./componen
|
||||
import { TaskContextHeader } from "./components/TaskContextHeader";
|
||||
import { TaskSpine } from "./components/TaskSpine";
|
||||
import {
|
||||
clearPlatformSession,
|
||||
confirmTask,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getPlatformSession,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
@ -118,12 +122,65 @@ function getNoReportSummary(task: TaskRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
function formatTimestamp(timestamp?: string) {
|
||||
if (!timestamp) {
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function getSearchRequirementLabel(
|
||||
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
||||
) {
|
||||
switch (searchRequirement) {
|
||||
case "required":
|
||||
return "需准备会话";
|
||||
case "recommended":
|
||||
return "建议预热";
|
||||
default:
|
||||
return "无需会话";
|
||||
}
|
||||
}
|
||||
|
||||
function getReadinessSummary(readiness: SessionReadinessRecord) {
|
||||
if (readiness.status === "ready") {
|
||||
return readiness.expiresAt
|
||||
? `当前工作区已有可复用会话,有效至 ${formatTimestamp(readiness.expiresAt)}。`
|
||||
: readiness.reason;
|
||||
}
|
||||
|
||||
if (readiness.status === "expired") {
|
||||
return `最近一次会话已过期,上次准备时间为 ${formatTimestamp(readiness.lastPreparedAt)}。`;
|
||||
}
|
||||
|
||||
return readiness.reason;
|
||||
}
|
||||
|
||||
function getSessionSnapshotSummary(session: SessionStateRecord) {
|
||||
if (session.status === "ready") {
|
||||
return session.expiresAt
|
||||
? `已保存加密快照,可复用至 ${formatTimestamp(session.expiresAt)}。`
|
||||
: "已保存加密快照,可用于当前工作区复用。";
|
||||
}
|
||||
|
||||
if (session.status === "expired") {
|
||||
return "最近一次会话已过期,需重新完成会话准备。";
|
||||
}
|
||||
|
||||
return "当前还没有可复用会话快照。";
|
||||
}
|
||||
|
||||
export function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: ["platform-readiness"],
|
||||
queryFn: getPlatformReadiness
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [perLinkLimit, setPerLinkLimit] = useState(100);
|
||||
const [taskTotalLimit, setTaskTotalLimit] = useState(500);
|
||||
@ -134,6 +191,7 @@ function NewTaskPage() {
|
||||
navigate(`/tasks/${task.taskId}/confirm`);
|
||||
}
|
||||
});
|
||||
const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@ -209,18 +267,63 @@ function NewTaskPage() {
|
||||
status={platform.ready ? "Completed" : "SearchBlocked"}
|
||||
/>
|
||||
</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
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</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">
|
||||
<p className="eyebrow">Scope Reminder</p>
|
||||
<h3>P0 当前不做什么</h3>
|
||||
@ -230,6 +333,7 @@ function NewTaskPage() {
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
@ -983,34 +1087,87 @@ export function HistoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPreparePage() {
|
||||
export function SessionPreparePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const platformId = platform as PlatformId;
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: ["session", platformId],
|
||||
queryFn: () => getPlatformSession(platformId)
|
||||
});
|
||||
const prepareMutation = useMutation({
|
||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||
mutationFn: () => preparePlatform(platformId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
|
||||
]);
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => clearPlatformSession(platformId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const session = sessionQuery.data?.session;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Session Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<h2>{platformCatalogMap[platformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platformId].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:prepare</strong>
|
||||
<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
|
||||
className="ghost-button"
|
||||
disabled={clearMutation.isPending || !session?.encryptedSnapshotAvailable}
|
||||
onClick={() => clearMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
清理当前会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -6,11 +6,13 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearPlatformSession: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
|
||||
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,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
.evidence-card,
|
||||
.mini-task-link {
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
border-radius: 18px;
|
||||
background: var(--bg-elevated);
|
||||
@ -231,7 +232,8 @@ a {
|
||||
.history-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
.evidence-card,
|
||||
.mini-task-link {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@ -256,6 +258,37 @@ a {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.readiness-card__caption {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mini-task-link {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mini-task-link__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-task-link p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
@ -424,6 +457,12 @@ a {
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.inline-note--subtle {
|
||||
padding: 8px 10px;
|
||||
background: rgba(31, 42, 48, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@ -568,6 +607,31 @@ a {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(31, 42, 48, 0.04);
|
||||
}
|
||||
|
||||
.session-details p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-details__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-return-target {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
@ -4,6 +4,27 @@ export type PlatformId = (typeof platforms)[number];
|
||||
export const searchRequirements = ["none", "recommended", "required"] as const;
|
||||
export type SearchRequirement = (typeof searchRequirements)[number];
|
||||
|
||||
export const sessionStatuses = ["missing", "ready", "expired"] as const;
|
||||
export type SessionStatus = (typeof sessionStatuses)[number];
|
||||
|
||||
export const strategyLayers = ["L0", "L1", "L2", "L3"] as const;
|
||||
export type StrategyLayer = (typeof strategyLayers)[number];
|
||||
|
||||
export const platformCapabilities = ["search", "detail", "reviews", "login"] as const;
|
||||
export type PlatformCapability = (typeof platformCapabilities)[number];
|
||||
|
||||
export const strategyAttemptOutcomes = [
|
||||
"succeeded",
|
||||
"blocked",
|
||||
"failed",
|
||||
"no_result",
|
||||
"skipped"
|
||||
] as const;
|
||||
export type StrategyAttemptOutcome = (typeof strategyAttemptOutcomes)[number];
|
||||
|
||||
export const strategyAttemptTriggers = ["system", "retry", "recovery"] as const;
|
||||
export type StrategyAttemptTrigger = (typeof strategyAttemptTriggers)[number];
|
||||
|
||||
export const taskStatuses = [
|
||||
"Draft",
|
||||
"Searching",
|
||||
@ -64,3 +85,18 @@ export type SampleFlag = (typeof sampleFlags)[number];
|
||||
|
||||
export const evidenceSourceTypes = ["product", "review"] as const;
|
||||
export type EvidenceSourceType = (typeof evidenceSourceTypes)[number];
|
||||
|
||||
export const retentionActions = ["task_deleted", "retention_cleanup"] as const;
|
||||
export type RetentionAction = (typeof retentionActions)[number];
|
||||
|
||||
export const auditActions = [
|
||||
"session.prepared",
|
||||
"session.cleared",
|
||||
"task.recovery_completed",
|
||||
"platform.retry_started",
|
||||
"platform.retry_completed",
|
||||
"task.deleted",
|
||||
"report.published",
|
||||
"report.unchanged"
|
||||
] as const;
|
||||
export type AuditAction = (typeof auditActions)[number];
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import type {
|
||||
AuditAction,
|
||||
PlatformCapability,
|
||||
PlatformId,
|
||||
PlatformStatus,
|
||||
SearchRequirement,
|
||||
ReportableTaskStatus,
|
||||
RetentionAction,
|
||||
SessionStatus,
|
||||
StrategyAttemptOutcome,
|
||||
StrategyAttemptTrigger,
|
||||
StrategyLayer,
|
||||
TaskStage,
|
||||
TaskStatus
|
||||
} from "./enums";
|
||||
@ -46,8 +54,120 @@ export interface TaskEventRecord {
|
||||
export interface SessionReadinessRecord {
|
||||
platform: PlatformId;
|
||||
ready: boolean;
|
||||
status: SessionStatus;
|
||||
searchRequirement: SearchRequirement;
|
||||
reason: string;
|
||||
lastPreparedAt?: string | undefined;
|
||||
expiresAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface SessionStateRecord {
|
||||
platform: PlatformId;
|
||||
ready: boolean;
|
||||
status: SessionStatus;
|
||||
searchRequirement: SearchRequirement;
|
||||
scope: "workspace";
|
||||
ttlHours: number;
|
||||
lastPreparedAt?: string | undefined;
|
||||
expiresAt?: string | undefined;
|
||||
encryptedSnapshotAvailable: boolean;
|
||||
cipherLabel?: string | undefined;
|
||||
}
|
||||
|
||||
export interface StrategyAttemptRecord {
|
||||
attemptId: string;
|
||||
taskId: string;
|
||||
platform: PlatformId;
|
||||
capability: PlatformCapability;
|
||||
layer: StrategyLayer;
|
||||
outcome: StrategyAttemptOutcome;
|
||||
trigger: StrategyAttemptTrigger;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
errorType?: string | undefined;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface PlatformRunMetricRecord {
|
||||
taskId: string;
|
||||
platform: PlatformId;
|
||||
searchDurationMs: number;
|
||||
detailDurationMs: number;
|
||||
reviewsDurationMs: number;
|
||||
retryCount: number;
|
||||
recoveryCount: number;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReportMetricRecord {
|
||||
taskId: string;
|
||||
reportVersion: number;
|
||||
taskStatus: ReportableTaskStatus;
|
||||
selectedLinkCount: number;
|
||||
reviewSampleCount: number;
|
||||
blockedPlatforms: PlatformId[];
|
||||
failedPlatforms: PlatformId[];
|
||||
sampleInsufficient: boolean;
|
||||
partialPlatformFailure: boolean;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface RetentionMetricRecord {
|
||||
metricId: string;
|
||||
action: RetentionAction;
|
||||
taskId?: string | undefined;
|
||||
deletedReportCount: number;
|
||||
deletedArtifactCount: number;
|
||||
residualArtifactCount: number;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export type AuditMetadataValue = string | number | boolean | null;
|
||||
export type AuditMetadata = Record<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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user