From 4d545a8d6719ed6dc742321ac532403b3798529e Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 23:32:46 +0000 Subject: [PATCH] fix(history): keep the lineage link on failed rows that show an ops hint renderActionCell returned the ops-hint badge alone for a failed row that carries ops_hint, so the row lost its retry-chain links ("Retried as #..." / "Retry #N"). Since #967 a failed execution the backend will never re-drive (redrive_unsafe) also carries a permanent hint, so a legacy row that was retried before the re-drive gate existed now shows the hint and no link to its successor. The lineage links move into a renderLineageLinks helper, and the ops-hint branch renders the badge followed by those links. Retry stays hidden for these rows. Refs #1040 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/history-retry-button.test.ts | 43 +++++++++++++++++ frontend/src/history.ts | 46 +++++++++++-------- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index 7867ee71d..7ed3495d4 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -272,6 +272,49 @@ describe('History inline Retry button (issue #47)', () => { expect(hint?.getAttribute('title')).not.toContain('Operator action required'); }); + test('ops_hint row keeps its lineage links next to the hint (issue #1040)', async () => { + // A legacy row retried before the re-drive gate (#967) now carries the + // permanent hint; it must still link to its successor, and Retry stays hidden. + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ + purchase_id: 'r-legacy', + created_by_user_id: ADMIN_USER.id, + ops_hint: 'Azure savings plans have no provider-side duplicate guard', + redrive_unsafe: true, + retry_execution_id: 'succ-5678-aaaa', + retry_attempt_n: 2, + }), + ], + }); + + await loadHistory(); + const row = document.querySelector('#history-list tr[data-execution-id="r-legacy"]')!; + expect(row.querySelector('.history-ops-hint')?.textContent).toContain('Azure savings plans'); + const link = row.querySelector('a.history-retry-link'); + expect(link?.textContent).toContain('Retried as #succ-567'); + expect(link?.getAttribute('href')).toBe('#history?execution=succ-5678-aaaa'); + expect(row.querySelector('.history-retry-of')?.textContent).toContain('Retry #2'); + expect(row.querySelectorAll('.history-retry-btn')).toHaveLength(0); + }); + + test('ops_hint row without lineage renders the hint alone', async () => { + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + makeRow({ purchase_id: 'r-1', created_by_user_id: ADMIN_USER.id, ops_hint: 'Set FROM_EMAIL tfvar then retry' }), + ], + }); + + await loadHistory(); + const row = document.querySelector('#history-list tr[data-execution-id="r-1"]')!; + expect(row.querySelector('.history-ops-hint')).not.toBeNull(); + expect(row.querySelectorAll('.history-retry-link')).toHaveLength(0); + }); + test('threshold-reached row renders override button + sends force=true on accept', async () => { (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); (confirmDialog as jest.Mock).mockResolvedValue(true); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index b4c183d44..a8c0c2a9d 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -922,11 +922,33 @@ function renderPendingActionButtons(p: HistoryPurchase): string { // // Decision tree (status-driven, mutually exclusive): // * pending|notified + canCancel → Cancel button (issue #46) -// * failed + ops_hint set → ⚠ ops-hint badge (issue #47, Q3 — no retry) +// * failed + ops_hint set → ⚠ ops-hint badge (issue #47, Q3 — no retry), +// plus any lineage link (issue #1040) // * failed + threshold reached → "Retried 5× — confirm to override" Retry button (Q2) // * failed + canRetry → standard ↻ Retry button // * any row with retry lineage → inline ↻ Retried as / ↻ Retry of link // * else → plan_name or "-" +// renderLineageLinks returns the inline retry-chain cross-links for a row: a +// row that was retried (retry_execution_id set) or is itself a retry +// (retry_attempt_n > 0) links to the other end of the chain so the user can +// navigate without scrolling. Both can be true simultaneously on a +// middle-of-chain row (failed_v2 was retried into v3 AND is itself a retry +// of v1). +function renderLineageLinks(p: HistoryPurchase): string[] { + const lineage: string[] = []; + if (p.retry_execution_id) { + lineage.push(`↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); + } + if ((p.retry_attempt_n ?? 0) > 0) { + // We don't carry a back-pointer field — a future enhancement + // could surface the predecessor's exec ID via the API. For now + // we render a static badge (no link target) so users at least + // see "this is a retry" provenance. + lineage.push(`↻ Retry #${p.retry_attempt_n}`); + } + return lineage; +} + function renderActionCell(p: HistoryPurchase): string { // Pending → render Approve + Cancel side-by-side when the session // qualifies for both (the typical case after issue #286 — the same @@ -946,7 +968,10 @@ function renderActionCell(p: HistoryPurchase): string { const title = p.redrive_unsafe ? 'Permanent: this execution can never be retried; check the account, then start a new purchase if needed' : 'Operator action required — Retry will not help until this is fixed'; - return `⚠ ${escapeHtml(p.ops_hint)}`; + // Keep the lineage links next to the hint (issue #1040): a legacy row + // retried before the re-drive gate (#967) now carries the permanent + // hint, and the operator still needs the link to its successor. + return [`⚠ ${escapeHtml(p.ops_hint)}`, ...renderLineageLinks(p)].join(' '); } if (canRetryFailedRow(p) && p.purchase_id) { const overThreshold = retryThresholdReached(p); @@ -956,22 +981,7 @@ function renderActionCell(p: HistoryPurchase): string { } } - // Lineage links: a row that was retried (retry_execution_id set) or - // is itself a retry (retry_attempt_n > 0) gets an inline cross-link - // to the other end of the chain so the user can navigate without - // scrolling. Both can be true simultaneously on a middle-of-chain - // row (failed_v2 was retried into v3 AND is itself a retry of v1). - const lineage: string[] = []; - if (p.retry_execution_id) { - lineage.push(`↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); - } - if ((p.retry_attempt_n ?? 0) > 0) { - // We don't carry a back-pointer field — a future enhancement - // could surface the predecessor's exec ID via the API. For now - // we render a static badge (no link target) so users at least - // see "this is a retry" provenance. - lineage.push(`↻ Retry #${p.retry_attempt_n}`); - } + const lineage = renderLineageLinks(p); // Build the trailing action buttons so they compose with lineage links // — a retry-descendant can also have an active listing that needs a // Cancel button, or be an Azure row still inside its revoke window.