arden scrape safety telemetry and polish run state UX
This commit is contained in:
parent
110e246a1c
commit
d7404abf18
33 changed files with 2074 additions and 96 deletions
|
|
@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { createDefaultSafetyState } from "@/features/safety";
|
||||
|
||||
vi.mock("@/features/runs", () => ({
|
||||
listRuns: vi.fn(),
|
||||
|
|
@ -40,6 +41,13 @@ function buildRun(overrides: Partial<{
|
|||
};
|
||||
}
|
||||
|
||||
function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
|
||||
return {
|
||||
runs,
|
||||
safety_state: createDefaultSafetyState(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("run status store", () => {
|
||||
const mockedListRuns = vi.mocked(listRuns);
|
||||
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
|
||||
|
|
@ -57,7 +65,7 @@ describe("run status store", () => {
|
|||
});
|
||||
|
||||
it("bootstraps from latest run and exposes idle start state", async () => {
|
||||
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 11, status: "success" })]);
|
||||
mockedListRuns.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 11, status: "success" })]));
|
||||
|
||||
const store = useRunStatusStore();
|
||||
await store.bootstrap();
|
||||
|
|
@ -80,8 +88,11 @@ describe("run status store", () => {
|
|||
new_publication_count: 0,
|
||||
reused_existing_run: false,
|
||||
idempotency_key: "abc",
|
||||
safety_state: createDefaultSafetyState(),
|
||||
});
|
||||
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 25, status: "running", end_dt: null })]);
|
||||
mockedListRuns.mockResolvedValueOnce(
|
||||
buildRunsPayload([buildRun({ id: 25, status: "running", end_dt: null })]),
|
||||
);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
const result = await store.startManualCheck();
|
||||
|
|
@ -106,7 +117,9 @@ describe("run status store", () => {
|
|||
requestId: "req_123",
|
||||
}),
|
||||
);
|
||||
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 42, status: "running", end_dt: null })]);
|
||||
mockedListRuns.mockResolvedValueOnce(
|
||||
buildRunsPayload([buildRun({ id: 42, status: "running", end_dt: null })]),
|
||||
);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
const result = await store.startManualCheck();
|
||||
|
|
@ -124,8 +137,8 @@ describe("run status store", () => {
|
|||
it("polls while a run is active and stops when it completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedListRuns
|
||||
.mockResolvedValueOnce([buildRun({ id: 90, status: "running", end_dt: null })])
|
||||
.mockResolvedValueOnce([buildRun({ id: 90, status: "success" })]);
|
||||
.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "running", end_dt: null })]))
|
||||
.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "success" })]));
|
||||
|
||||
const store = useRunStatusStore();
|
||||
await store.syncLatest();
|
||||
|
|
@ -139,6 +152,42 @@ describe("run status store", () => {
|
|||
expect(store.isRunActive).toBe(false);
|
||||
});
|
||||
|
||||
it("stores cooldown safety state when manual start is blocked by policy cooldown", async () => {
|
||||
mockedTriggerManualRun.mockRejectedValueOnce(
|
||||
new ApiRequestError({
|
||||
status: 429,
|
||||
code: "scrape_cooldown_active",
|
||||
message: "Scrape safety cooldown is active; run start is temporarily blocked.",
|
||||
details: {
|
||||
safety_state: {
|
||||
cooldown_active: true,
|
||||
cooldown_reason: "blocked_failure_threshold_exceeded",
|
||||
cooldown_reason_label: "Blocked responses exceeded safety threshold",
|
||||
cooldown_until: "2026-02-19T12:30:00Z",
|
||||
cooldown_remaining_seconds: 600,
|
||||
recommended_action: "Wait for cooldown to expire.",
|
||||
counters: {
|
||||
consecutive_blocked_runs: 1,
|
||||
consecutive_network_runs: 0,
|
||||
cooldown_entry_count: 1,
|
||||
blocked_start_count: 2,
|
||||
last_blocked_failure_count: 1,
|
||||
last_network_failure_count: 0,
|
||||
last_evaluated_run_id: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useRunStatusStore();
|
||||
const result = await store.startManualCheck();
|
||||
|
||||
expect(result.kind).toBe("error");
|
||||
expect(store.safetyState.cooldown_active).toBe(true);
|
||||
expect(store.canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("switches from starting to in-progress when trigger request remains open", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedTriggerManualRun.mockImplementation(
|
||||
|
|
@ -155,11 +204,12 @@ describe("run status store", () => {
|
|||
new_publication_count: 0,
|
||||
reused_existing_run: false,
|
||||
idempotency_key: "x",
|
||||
safety_state: createDefaultSafetyState(),
|
||||
});
|
||||
}, RUN_STATUS_STARTING_PHASE_MS + 500);
|
||||
}),
|
||||
);
|
||||
mockedListRuns.mockResolvedValueOnce([]);
|
||||
mockedListRuns.mockResolvedValueOnce(buildRunsPayload([]));
|
||||
|
||||
const store = useRunStatusStore();
|
||||
const startPromise = store.startManualCheck();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
|
||||
import {
|
||||
createDefaultSafetyState,
|
||||
normalizeSafetyState,
|
||||
type ScrapeSafetyState,
|
||||
} from "@/features/safety";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
|
||||
|
|
@ -49,6 +54,19 @@ function extractRunIdFromDetails(details: unknown): number | null {
|
|||
return parseRunId(runIdCandidate);
|
||||
}
|
||||
|
||||
function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | null {
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = (details as Record<string, unknown>).safety_state;
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSafetyState(candidate);
|
||||
}
|
||||
|
||||
function buildPlaceholderRunningRun(runId: number): RunListItem {
|
||||
return {
|
||||
id: runId,
|
||||
|
|
@ -66,6 +84,7 @@ function buildPlaceholderRunningRun(runId: number): RunListItem {
|
|||
export const useRunStatusStore = defineStore("runStatus", {
|
||||
state: () => ({
|
||||
latestRun: null as RunListItem | null,
|
||||
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
|
||||
isSubmitting: false,
|
||||
assumeRunningFromSubmission: false,
|
||||
isPolling: false,
|
||||
|
|
@ -81,7 +100,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
|
||||
},
|
||||
canStart(): boolean {
|
||||
return !this.isRunActive;
|
||||
return !this.isRunActive && !this.safetyState.cooldown_active;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -113,6 +132,9 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
}
|
||||
this.updatePolling();
|
||||
},
|
||||
setSafetyState(value: unknown): void {
|
||||
this.safetyState = normalizeSafetyState(value);
|
||||
},
|
||||
startPolling(): void {
|
||||
if (pollTimer !== null) {
|
||||
this.isPolling = true;
|
||||
|
|
@ -146,9 +168,10 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
|
||||
syncPromise = (async () => {
|
||||
try {
|
||||
const runs = await listRuns({ limit: 1 });
|
||||
const latest = runs[0] ?? null;
|
||||
const payload = await listRuns({ limit: 1 });
|
||||
const latest = payload.runs[0] ?? null;
|
||||
this.latestRun = latest;
|
||||
this.safetyState = normalizeSafetyState(payload.safety_state);
|
||||
this.lastSyncAt = Date.now();
|
||||
this.lastErrorMessage = null;
|
||||
this.lastErrorRequestId = null;
|
||||
|
|
@ -184,6 +207,18 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
requestId: null,
|
||||
};
|
||||
}
|
||||
if (this.safetyState.cooldown_active) {
|
||||
const message =
|
||||
this.safetyState.recommended_action ||
|
||||
"Scrape safety cooldown is active; run start is temporarily blocked.";
|
||||
this.lastErrorMessage = message;
|
||||
this.lastErrorRequestId = null;
|
||||
return {
|
||||
kind: "error",
|
||||
message,
|
||||
requestId: null,
|
||||
};
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
this.lastErrorMessage = null;
|
||||
|
|
@ -193,6 +228,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
|
||||
try {
|
||||
const result = await triggerManualRun();
|
||||
this.safetyState = normalizeSafetyState(result.safety_state);
|
||||
await this.syncLatest();
|
||||
|
||||
if (!this.latestRun || this.latestRun.id !== result.run_id) {
|
||||
|
|
@ -223,6 +259,20 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
};
|
||||
}
|
||||
|
||||
if (error instanceof ApiRequestError && error.code === "scrape_cooldown_active") {
|
||||
const safetyState = extractSafetyStateFromDetails(error.details);
|
||||
if (safetyState) {
|
||||
this.safetyState = safetyState;
|
||||
}
|
||||
this.lastErrorMessage = error.message;
|
||||
this.lastErrorRequestId = error.requestId;
|
||||
return {
|
||||
kind: "error",
|
||||
message: error.message,
|
||||
requestId: error.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof ApiRequestError) {
|
||||
this.lastErrorMessage = error.message;
|
||||
this.lastErrorRequestId = error.requestId;
|
||||
|
|
@ -259,6 +309,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
|||
this.lastErrorMessage = null;
|
||||
this.lastErrorRequestId = null;
|
||||
this.lastSyncAt = null;
|
||||
this.safetyState = createDefaultSafetyState();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
import { fetchSettings, type UserSettings } from "@/features/settings";
|
||||
import { createDefaultSafetyState, normalizeSafetyState, type ScrapeSafetyState } from "@/features/safety";
|
||||
|
||||
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
|
||||
export const DEFAULT_NAV_VISIBLE_PAGES = [
|
||||
|
|
@ -51,6 +52,15 @@ function normalizeNavVisiblePages(value: unknown): string[] {
|
|||
export const useUserSettingsStore = defineStore("userSettings", {
|
||||
state: () => ({
|
||||
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
|
||||
minRunIntervalMinutes: 15,
|
||||
minRequestDelaySeconds: 2,
|
||||
automationAllowed: true,
|
||||
manualRunAllowed: true,
|
||||
blockedFailureThreshold: 1,
|
||||
networkFailureThreshold: 2,
|
||||
cooldownBlockedSeconds: 1800,
|
||||
cooldownNetworkSeconds: 900,
|
||||
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
|
||||
}),
|
||||
getters: {
|
||||
visiblePageSet: (state) => new Set(state.navVisiblePages),
|
||||
|
|
@ -61,9 +71,39 @@ export const useUserSettingsStore = defineStore("userSettings", {
|
|||
},
|
||||
applySettings(settings: UserSettings): void {
|
||||
this.setNavVisiblePages(settings.nav_visible_pages);
|
||||
this.minRunIntervalMinutes = Number.isFinite(settings.policy?.min_run_interval_minutes)
|
||||
? Math.max(15, settings.policy.min_run_interval_minutes)
|
||||
: 15;
|
||||
this.minRequestDelaySeconds = Number.isFinite(settings.policy?.min_request_delay_seconds)
|
||||
? Math.max(2, settings.policy.min_request_delay_seconds)
|
||||
: 2;
|
||||
this.automationAllowed = Boolean(settings.policy?.automation_allowed ?? true);
|
||||
this.manualRunAllowed = Boolean(settings.policy?.manual_run_allowed ?? true);
|
||||
this.blockedFailureThreshold = Number.isFinite(settings.policy?.blocked_failure_threshold)
|
||||
? Math.max(1, settings.policy.blocked_failure_threshold)
|
||||
: 1;
|
||||
this.networkFailureThreshold = Number.isFinite(settings.policy?.network_failure_threshold)
|
||||
? Math.max(1, settings.policy.network_failure_threshold)
|
||||
: 1;
|
||||
this.cooldownBlockedSeconds = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_blocked_seconds)
|
||||
: 1800;
|
||||
this.cooldownNetworkSeconds = Number.isFinite(settings.policy?.cooldown_network_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_network_seconds)
|
||||
: 900;
|
||||
this.safetyState = normalizeSafetyState(settings.safety_state);
|
||||
},
|
||||
reset(): void {
|
||||
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
|
||||
this.minRunIntervalMinutes = 15;
|
||||
this.minRequestDelaySeconds = 2;
|
||||
this.automationAllowed = true;
|
||||
this.manualRunAllowed = true;
|
||||
this.blockedFailureThreshold = 1;
|
||||
this.networkFailureThreshold = 2;
|
||||
this.cooldownBlockedSeconds = 1800;
|
||||
this.cooldownNetworkSeconds = 900;
|
||||
this.safetyState = createDefaultSafetyState();
|
||||
},
|
||||
isPageVisible(pageId: string): boolean {
|
||||
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue