Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ describe('handleFanOutExecute — fan-out path', () => {
return {
key: `key-${id}`,
label: `Bucket ${id}`,
provider: 'aws',
service: `svc-${id}`,
recs: [buildMinimalRec()],
payment: 'all-upfront',
capacityPercent,
Expand Down Expand Up @@ -414,6 +416,38 @@ describe('handleFanOutExecute — fan-out path', () => {
expect(lastToastKind()).toBe('warning');
});

test('#642 — partial fan-out failure names the submitted (still-actionable) buckets', async () => {
(recs.getFanOutBuckets as jest.Mock).mockReturnValue([
buildBucket('a'),
buildBucket('b'),
]);

// Bucket a submits cleanly (creates a pending execution); bucket b fails.
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({
execution_id: 'exec-a',
status: 'queued',
email_sent: true,
approval_recipient: 'alice@example.com',
})
.mockRejectedValueOnce(new Error('network down'));

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

const msg = lastToastMessage();
expect(msg).toContain('1 of 2');
expect(msg).toContain('failed');
// The orphaned-but-actionable pending execution must be surfaced by name so
// the user knows which request is live (issue #642).
expect(msg).toContain('Submitted (awaiting approval)');
expect(msg).toContain('AWS svc-a@100%');
// The failed bucket must NOT be listed as submitted.
expect(msg).not.toContain('svc-b');
expect(lastToastKind()).toBe('warning');
});

test('Finding 2 — status === "failed" also counts as failure, recipient excluded', async () => {
(recs.getFanOutBuckets as jest.Mock).mockReturnValue([
buildBucket('a'),
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,20 @@ async function handleExecutePurchase(): Promise<void> {
}
}

/**
* fanOutBucketLabel renders a short human identifier for a fan-out bucket so a
* partial-failure toast can name which buckets created pending executions
* (issue #642). Uses the (provider/service @ capacity%) tuple — the same
* fields the user chose in the modal — so the orphaned-but-actionable pending
* requests are identifiable rather than hidden behind a bare count.
*/
function fanOutBucketLabel(b: FanOutBucket): string {
const provider = (b.provider || '').toString().toUpperCase();
const service = b.service || 'commitment';
const cap = Number.isFinite(b.capacityPercent) ? `@${b.capacityPercent}%` : '';
return `${provider} ${service}${cap}`.trim();
}

/**
* handleFanOutExecute submits one executePurchase POST per bucket.
*
Expand Down Expand Up @@ -503,8 +517,28 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
]
.slice(0, 3)
.join('; ');
// #642: on a partial fan-out failure the succeeded buckets created real
// pending executions an approver can still act on. Name them so the user
// knows which requests are live (and which to re-submit) rather than
// leaving them as silent orphans behind a bare count.
let submittedNote = '';
if (succeeded > 0 && succeeded < results.length) {
const submittedLabels: string[] = [];
results.forEach((r, i) => {
const ok =
r.status === 'fulfilled' &&
r.value.email_sent !== false &&
r.value.status !== 'failed';
if (ok && buckets[i]) submittedLabels.push(fanOutBucketLabel(buckets[i]));
});
if (submittedLabels.length > 0) {
submittedNote = ` Submitted (awaiting approval): ${submittedLabels.slice(0, 5).join(', ')}${
submittedLabels.length > 5 ? ` +${submittedLabels.length - 5} more` : ''
}.`;
}
}
showToast({
message: `${succeeded} of ${results.length} submitted · ${failed} failed: ${failureMsgs}${failed > 3 ? ' (…)' : ''}`,
message: `${succeeded} of ${results.length} submitted · ${failed} failed: ${failureMsgs}${failed > 3 ? ' (…)' : ''}${submittedNote}`,
kind: failed === results.length ? 'error' : 'warning',
timeout: null,
});
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ function statusBadgeHTML(status: string): string {
return '<span class="badge badge-warning">In Progress</span>';
case 'cancelled':
return '<span class="badge badge-muted">Cancelled</span>';
case 'partially_completed':
// #642: some commitments succeeded, some failed. Not a clean success
// and never "failed" (real commitments exist) — a distinct warning badge
// so the user knows to read the description and reconcile the failures.
return '<span class="badge badge-warning">Partial</span>';
case 'failed':
return '<span class="badge badge-danger">Failed</span>';
case 'expired':
Expand Down
19 changes: 18 additions & 1 deletion internal/api/handler_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,14 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR
// purchase_history rows (no duplicate). The execution row's PurchaseID is
// the ExecutionID while a purchase_history row's is the CommitmentID, so
// the keys never collide even when both happen to render.
var historyExecutionStatuses = []string{"pending", "notified", "approved", "running", "paused", "completed", "failed", "expired", "cancelled"}
//
// "partially_completed" (issue #642) is loaded and ALWAYS synthesised: a
// partial run committed some recs to purchase_history (those render from the
// DB rows) and failed others. The synthesised execution row carries the
// partial-failure marker and is flagged IsAuditGap so its execution-level
// dollars are excluded from the dashboard totals — the committed dollars are
// already counted via the per-rec purchase_history rows that succeeded.
var historyExecutionStatuses = []string{"pending", "notified", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "cancelled"}

// approvalExpiryWindow is how long a pending approval stays actionable
// before the History view flips it to "expired". Aligns with the
Expand Down Expand Up @@ -231,6 +238,16 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P
case "paused":
row.Approver = approver
row.StatusDescription = "purchase paused — resume or cancel from the plan"
case "partially_completed":
// #642: some recs committed, some failed. The committed recs are
// surfaced via their own purchase_history rows; this synthesised row
// is the audit flag for the failures. Flag IsAuditGap so the dashboard
// excludes its execution-level dollars (the committed dollars are
// counted on the per-rec purchase_history rows, not here) — same
// double-count guard as the audit-gap completed case below.
row.IsAuditGap = true
annotateApproved(row, exec, approver)
row.StatusDescription = "partially completed — some commitments succeeded, others failed: " + exec.Error
case "completed":
// Only audit-gap completed executions reach here (fetchExecutionsAsHistory
// skips clean completed rows). exec.Error carries why the history write
Expand Down
45 changes: 45 additions & 0 deletions internal/api/handler_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,51 @@ func TestHandler_getHistory_AuditGapCompletedVisible(t *testing.T) {
assert.Equal(t, 0.0, resp.Summary.TotalMonthlySavings)
}

// TestHandler_getHistory_PartiallyCompletedVisible is the issue #642 regression
// guard. A "partially_completed" execution (some recs committed, some failed)
// must be surfaced in History as a non-failed row carrying a clear description,
// count as completed (money was committed), and have its execution-level
// dollars excluded via IsAuditGap (the committed dollars are counted on the
// per-rec purchase_history rows that actually saved, not here).
func TestHandler_getHistory_PartiallyCompletedVisible(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)

partial := []config.PurchaseExecution{
{
ExecutionID: "partial-1",
Status: "partially_completed",
Error: "some purchases failed (partial success): c5.xlarge: offering not available",
ScheduledDate: time.Now(),
TotalUpfrontCost: 900.0,
EstimatedSavings: 140.0,
Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1"}},
},
}
approver := "ops@example.com"
mockStore.On("GetAllPurchaseHistory", ctx, 100).Return([]config.PurchaseHistoryRecord{}, nil)
mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(partial, nil)
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil)

mockAuth, req := adminHistoryReq(ctx)
handler := &Handler{auth: mockAuth, config: mockStore}

result, err := handler.getHistory(ctx, req, map[string]string{})
require.NoError(t, err)
resp := result.(HistoryResponse)

require.Len(t, resp.Purchases, 1, "partially_completed execution must be surfaced in History (issue #642)")
row := resp.Purchases[0]
assert.Equal(t, "partial-1", row.PurchaseID)
assert.Equal(t, "partially_completed", row.Status)
assert.NotEqual(t, "failed", row.Status, "a partial run with real commitments must never read as failed (double-spend hazard)")
assert.Contains(t, row.StatusDescription, "partially completed", "the partial outcome must be surfaced to the user")
assert.True(t, row.IsAuditGap, "partial row must carry IsAuditGap so its execution-level dollars are excluded")
assert.Equal(t, 1, resp.Summary.TotalCompleted, "money was committed, so it counts as completed")
assert.Equal(t, 0.0, resp.Summary.TotalUpfront, "partial row must not contribute execution-level dollars (committed dollars come from purchase_history rows)")
assert.Equal(t, 0.0, resp.Summary.TotalMonthlySavings)
}

// TestHandler_getHistory_CompletedDBRowWithDescriptionStillCounts guards the
// financial invariant that dollar exclusion keys off the explicit IsAuditGap
// marker, NOT off StatusDescription being set. A real purchase_history row
Expand Down
Loading
Loading