feat(awooop): preview recurrence repair work items
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m11s
CD Pipeline / build-and-deploy (push) Successful in 3m33s
CD Pipeline / post-deploy-checks (push) Successful in 1m32s

This commit is contained in:
Your Name
2026-05-18 21:42:20 +08:00
parent 51660ecbb1
commit d1ebcdac10
6 changed files with 925 additions and 4 deletions

View File

@@ -104,6 +104,60 @@ type RecurrenceResponse = {
items: RecurrenceItem[];
};
type RecurrenceWorkItemActionResult = {
schema_version?: string;
work_item_id?: string | null;
incident_id?: string | null;
mode?: string | null;
requested_mode?: string | null;
allowed?: boolean | null;
executed?: boolean | null;
safety_level?: string | null;
writes_incident_state?: boolean | null;
writes_auto_repair_result?: boolean | null;
writes_ticket?: boolean | null;
verification_result_preview?: string | null;
next_step?: string | null;
checks?: Array<{ name?: string | null; passed?: boolean | null; detail?: string | null }>;
current_state_summary?: {
repair_status?: string | null;
occurrence_total?: number | null;
duplicate_total?: number | null;
linked_run_total?: number | null;
} | null;
ticket_preview?: {
would_create?: boolean | null;
title?: string | null;
labels?: string[] | null;
body_preview?: string | null;
} | null;
plan?: {
step?: string | null;
flywheel_node?: string | null;
agent_id?: string | null;
required_scope?: string | null;
target_action?: string | null;
} | null;
read_model_route?: {
agent_id?: string | null;
tool_name?: string | null;
required_scope?: string | null;
flywheel_node?: string | null;
} | null;
history?: {
recorded?: boolean | null;
reason?: string | null;
alert_operation_id?: string | null;
timeline_event_id?: string | null;
} | null;
};
type RecurrenceWorkItemActionState = {
loading?: "preview" | "dryRun" | null;
result?: RecurrenceWorkItemActionResult | null;
error?: string | null;
};
type SloResponse = {
adr100?: {
verification_coverage?: {
@@ -194,6 +248,30 @@ async function fetchJson<T>(url: string, timeoutMs = 8000): Promise<T | null> {
}
}
async function postJson<T>(
url: string,
body: Record<string, unknown>,
timeoutMs = 8000
): Promise<T | null> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
} finally {
window.clearTimeout(timeout);
}
}
function hasGateFailure(summary: AutomationQualitySummary | null, gate: string) {
return Boolean(summary?.gate_failures?.some((row) => row.gate === gate && row.total > 0));
}
@@ -227,6 +305,32 @@ function recurrenceRepairStatusKey(status?: string | null) {
return "unknown";
}
function recurrenceActionModeKey(mode?: string | null) {
if (
mode === "auto" ||
mode === "ticket" ||
mode === "reverify" ||
mode === "approval_review" ||
mode === "observe"
) {
return mode;
}
return "unknown";
}
function recurrencePreviewKey(preview?: string | null) {
if (
preview === "ticket_preview_ready" ||
preview === "reverify_preview_ready" ||
preview === "approval_review_required" ||
preview === "observe_only" ||
preview === "blocked"
) {
return preview;
}
return "unknown";
}
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType<typeof useTranslations>
@@ -538,6 +642,7 @@ function RecurrenceWorkQueuePanel({
projectId: string;
}) {
const t = useTranslations("awooop.workItems.recurrence");
const [actionState, setActionState] = useState<Record<string, RecurrenceWorkItemActionState>>({});
const openItems = recurrenceOpenItems(recurrence);
const focusedItem = focusedWorkItemId
? openItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId)
@@ -546,6 +651,42 @@ function RecurrenceWorkQueuePanel({
? [focusedItem, ...openItems.filter((item) => item !== focusedItem).slice(0, 5)]
: openItems.slice(0, 6);
const summary = recurrence?.summary;
const runWorkItemAction = useCallback(async (
workItemId: string,
action: "preview" | "dryRun"
) => {
setActionState((current) => ({
...current,
[workItemId]: { ...current[workItemId], loading: action, error: null },
}));
const encodedProjectId = encodeURIComponent(projectId);
const encodedWorkItemId = encodeURIComponent(workItemId);
const result = action === "preview"
? await fetchJson<RecurrenceWorkItemActionResult>(
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`,
12000
)
: await postJson<RecurrenceWorkItemActionResult>(
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`,
{
project_id: projectId,
work_item_id: workItemId,
mode: "auto",
limit: 300,
},
15000
);
setActionState((current) => ({
...current,
[workItemId]: {
loading: null,
result,
error: result ? null : t("actions.failed"),
},
}));
}, [projectId, t]);
return (
<section className="border border-[#e0ddd4] bg-white">
@@ -582,17 +723,23 @@ function RecurrenceWorkQueuePanel({
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-3">
{visibleItems.map((item) => {
const workItem = item.work_item;
const workItemId = workItem?.work_item_id ?? "";
const isFocused = Boolean(
focusedWorkItemId && workItem?.work_item_id === focusedWorkItemId
focusedWorkItemId && workItemId === focusedWorkItemId
);
const repairStatusKey = recurrenceRepairStatusKey(item.repair_summary?.status);
const runHref = item.latest_run_id
? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(projectId)}`
: null;
const currentAction = workItemId ? actionState[workItemId] : null;
const actionResult = currentAction?.result;
const actionAllowed = actionResult?.allowed === true;
const actionModeKey = recurrenceActionModeKey(actionResult?.mode);
const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview);
return (
<article
key={workItem?.work_item_id ?? item.recurrence_key}
key={workItemId || item.recurrence_key}
className={cn(
"bg-white px-4 py-3",
isFocused && "outline outline-2 outline-[#d97757]"
@@ -631,6 +778,32 @@ function RecurrenceWorkQueuePanel({
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{workItemId ? (
<>
<button
type="button"
onClick={() => runWorkItemAction(workItemId, "preview")}
disabled={currentAction?.loading === "preview"}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{currentAction?.loading === "preview"
? t("actions.previewing")
: t("actions.preview")}
</button>
<button
type="button"
onClick={() => runWorkItemAction(workItemId, "dryRun")}
disabled={currentAction?.loading === "dryRun"}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d97757] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<Gauge className="h-3.5 w-3.5" aria-hidden="true" />
{currentAction?.loading === "dryRun"
? t("actions.dryRunning")
: t("actions.dryRun")}
</button>
</>
) : null}
{runHref ? (
<Link
href={runHref as never}
@@ -648,6 +821,58 @@ function RecurrenceWorkQueuePanel({
{t("openRuns")}
</Link>
</div>
{currentAction?.error ? (
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2 text-xs leading-5 text-[#9f2f25]">
{currentAction.error}
</div>
) : null}
{actionResult ? (
<div
className={cn(
"mt-3 border px-3 py-2 text-xs leading-5",
actionAllowed
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold">
{actionAllowed ? t("actions.allowed") : t("actions.blocked")}
</span>
<span className="font-mono">
{t("actions.mode", {
mode: t(`actions.modes.${actionModeKey}` as never),
})}
</span>
</div>
<div className="mt-1 grid gap-1 text-[#5f5b52]">
<p>
{t("actions.previewResult", {
result: t(`actions.previews.${previewKey}` as never),
})}
</p>
<p>
{t("actions.writes", {
incident: String(actionResult.writes_incident_state ?? false),
autoRepair: String(actionResult.writes_auto_repair_result ?? false),
ticket: String(actionResult.writes_ticket ?? false),
})}
</p>
<p>
{t("actions.history", {
recorded: String(actionResult.history?.recorded ?? false),
})}
</p>
{actionResult.ticket_preview?.title ? (
<p className="truncate">
{t("actions.ticket", {
title: actionResult.ticket_preview.title,
})}
</p>
) : null}
</div>
</div>
) : null}
</article>
);
})}