diff --git a/src/components/UpdateCard.test.tsx b/src/components/UpdateCard.test.tsx
index 70b9704..7678e76 100644
--- a/src/components/UpdateCard.test.tsx
+++ b/src/components/UpdateCard.test.tsx
@@ -16,7 +16,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UpdateCard } from './UpdateCard';
-import type { UpdateEntry } from '@/lib/types';
+import type { UpdateEntry, UpdatePhase } from '@/lib/types';
describe('UpdateCard', () => {
it('renders update ID', () => {
@@ -79,6 +79,75 @@ describe('UpdateCard', () => {
expect(screen.getByText('completed')).toBeInTheDocument();
});
+ it('only offers Delete when an update completed with no x-medkit-phase', () => {
+ const entry: UpdateEntry = {
+ id: 'update-done',
+ status: { status: 'completed' },
+ };
+
+ render();
+
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument();
+ });
+
+ it('surfaces Execute alongside Delete when completed maps to phase=prepared', () => {
+ // Plugins that split prepare/execute expose both terminal states as
+ // SOVD status=completed. The phase disambiguates: completed +
+ // prepared means the install still needs to run.
+ const entry: UpdateEntry = {
+ id: 'update-prepared',
+ status: { status: 'completed', 'x-medkit-phase': 'prepared' },
+ };
+
+ render();
+
+ expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ });
+
+ it('drops Execute once phase advances past prepared', () => {
+ const entry: UpdateEntry = {
+ id: 'update-executed',
+ status: { status: 'completed', 'x-medkit-phase': 'executed' },
+ };
+
+ render();
+
+ expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ });
+
+ it('falls back to Delete-only when completed carries an unknown phase', () => {
+ // Contract: only `prepared` surfaces Execute. Any other (or
+ // out-of-enum) phase on `completed` is treated as terminal.
+ const entry: UpdateEntry = {
+ id: 'update-unknown-phase',
+ status: {
+ status: 'completed',
+ 'x-medkit-phase': 'installing' as UpdatePhase,
+ },
+ };
+
+ render();
+
+ expect(screen.queryByRole('button', { name: /^execute$/i })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ });
+
+ it('ignores phase on failed updates and offers the retry actions', () => {
+ const entry: UpdateEntry = {
+ id: 'update-failed-after-prepare',
+ status: { status: 'failed', 'x-medkit-phase': 'prepared' },
+ };
+
+ render();
+
+ expect(screen.getByRole('button', { name: /prepare/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^execute$/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ });
+
it('shows failed badge with error message text', () => {
const entry: UpdateEntry = {
id: 'update-failed',
diff --git a/src/components/UpdateCard.tsx b/src/components/UpdateCard.tsx
index b30d9b8..569109f 100644
--- a/src/components/UpdateCard.tsx
+++ b/src/components/UpdateCard.tsx
@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Package, Loader2, AlertCircle, FileText } from 'lucide-react';
import { fetchUpdateDetail } from '@/lib/updates-api';
-import type { UpdateEntry, UpdateStatusValue } from '@/lib/types';
+import type { UpdateEntry, UpdateStatus, UpdateStatusValue } from '@/lib/types';
export type UpdateAction = 'prepare' | 'execute' | 'automated' | 'delete';
@@ -58,16 +58,27 @@ function progressBarColor(status: UpdateStatusValue): string {
}
}
-function actionButtonsForStatus(status: UpdateStatusValue): UpdateAction[] {
- switch (status) {
+function actionButtonsForStatus(status: UpdateStatus): UpdateAction[] {
+ // SOVD collapses the prepare + execute pipeline into a single
+ // `completed` terminal status. Plugins that split the pipeline keep the
+ // real phase on the `x-medkit-phase` vendor field, so when
+ // status=completed + phase=prepared we are only half done and must
+ // surface Execute / Delete. Any other completed update (phase missing
+ // or `executed`) is truly terminal and only Delete applies. The
+ // `default` keeps the UI safe if a plugin emits a status outside the
+ // documented enum.
+ const phase = status['x-medkit-phase'];
+ switch (status.status) {
case 'pending':
return ['prepare', 'execute', 'automated', 'delete'];
case 'inProgress':
return [];
case 'completed':
- return ['delete'];
+ return phase === 'prepared' ? ['execute', 'delete'] : ['delete'];
case 'failed':
return ['prepare', 'execute', 'delete'];
+ default:
+ return ['delete'];
}
}
@@ -213,7 +224,7 @@ export function UpdateCard({ entry, baseUrl, busy, onAction }: UpdateCardProps)
{onAction &&
- actionButtonsForStatus(status.status).map((action) => (
+ actionButtonsForStatus(status).map((action) => (