mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
30 KiB
30 KiB
03 - AI-Assisted Migration
Overview
An optional AI-powered system that uses Claude to automatically migrate complex React code patterns that can't be fixed with simple find-and-replace. Includes budget controls, retry logic, and human-in-the-loop decision points.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Migration Wizard │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ AI Migration Controller │ │
│ │ • Budget management │ │
│ │ • Queue management │ │
│ │ • Retry orchestration │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Code Analyzer │ │ Claude Client │ │
│ │ (Babel AST) │ │ (API Calls) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
AI Configuration UI
First-Time Setup
// packages/noodl-editor/src/editor/src/views/migration/AIConfigPanel.tsx
interface AIConfigPanelProps {
existingConfig?: AIConfig;
onSave: (config: AIConfig) => void;
onCancel: () => void;
}
interface AIConfig {
apiKey: string;
budget: {
maxPerSession: number;
pauseIncrement: number;
showEstimates: boolean;
};
preferences: {
preferFunctional: boolean; // Prefer converting to functional components
preserveComments: boolean; // Keep existing code comments
verboseOutput: boolean; // Add explanatory comments
};
}
function AIConfigPanel({ existingConfig, onSave, onCancel }: AIConfigPanelProps) {
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || '');
const [maxBudget, setMaxBudget] = useState(existingConfig?.budget.maxPerSession || 5);
const [pauseIncrement, setPauseIncrement] = useState(
existingConfig?.budget.pauseIncrement || 5
);
const [showEstimates, setShowEstimates] = useState(
existingConfig?.budget.showEstimates ?? true
);
const [preferFunctional, setPreferFunctional] = useState(
existingConfig?.preferences.preferFunctional ?? true
);
const [validating, setValidating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const validateApiKey = async () => {
setValidating(true);
setValidationError(null);
try {
await testAnthropicKey(apiKey);
return true;
} catch (error) {
setValidationError(error.message);
return false;
} finally {
setValidating(false);
}
};
const handleSave = async () => {
if (await validateApiKey()) {
onSave({
apiKey,
budget: {
maxPerSession: maxBudget,
pauseIncrement,
showEstimates
},
preferences: {
preferFunctional,
preserveComments: true,
verboseOutput: true
}
});
}
};
return (
<div className={css['ai-config-panel']}>
<div className={css['ai-config-header']}>
<RobotIcon size={24} />
<div>
<h3>Configure AI Migration Assistant</h3>
<p>
OpenNoodl uses Claude (by Anthropic) to intelligently migrate
complex code patterns.
</p>
</div>
</div>
{/* API Key Section */}
<section className={css['config-section']}>
<h4>Anthropic API Key</h4>
<p>
You'll need an API key from Anthropic.
<a href="https://console.anthropic.com" target="_blank">
Get one here →
</a>
</p>
<div className={css['api-key-input']}>
<input
type="password"
placeholder="sk-ant-api03-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={validationError ? css['input-error'] : ''}
/>
<Button
variant="secondary"
size="small"
onClick={validateApiKey}
disabled={!apiKey || validating}
>
{validating ? <Spinner size={14} /> : 'Validate'}
</Button>
</div>
{validationError && (
<div className={css['validation-error']}>
<ErrorIcon size={14} />
{validationError}
</div>
)}
<div className={css['security-note']}>
<LockIcon size={14} />
<span>
Your API key is stored locally and encrypted. It's never sent to
OpenNoodl servers - all API calls go directly to Anthropic.
</span>
</div>
</section>
{/* Budget Section */}
<section className={css['config-section']}>
<h4>Budget Controls</h4>
<div className={css['config-field']}>
<label>Maximum spend per migration session</label>
<div className={css['budget-input']}>
<span>$</span>
<input
type="number"
min={1}
max={100}
step={1}
value={maxBudget}
onChange={(e) => setMaxBudget(Number(e.target.value))}
/>
</div>
<span className={css['field-hint']}>
Typical migration: $0.10 - $2.00
</span>
</div>
<div className={css['config-field']}>
<label>
<input
type="checkbox"
checked={pauseIncrement > 0}
onChange={(e) => setPauseIncrement(e.target.checked ? 5 : 0)}
/>
Pause and ask before each ${pauseIncrement} increment
</label>
</div>
<div className={css['config-field']}>
<label>
<input
type="checkbox"
checked={showEstimates}
onChange={(e) => setShowEstimates(e.target.checked)}
/>
Show cost estimate before each component
</label>
</div>
</section>
{/* Preferences Section */}
<section className={css['config-section']}>
<h4>Migration Preferences</h4>
<div className={css['config-field']}>
<label>
<input
type="checkbox"
checked={preferFunctional}
onChange={(e) => setPreferFunctional(e.target.checked)}
/>
Prefer converting to functional components with hooks
</label>
<span className={css['field-hint']}>
When possible, Claude will convert class components to
modern functional components
</span>
</div>
</section>
<div className={css['config-actions']}>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!apiKey || validating}
>
Save & Continue
</Button>
</div>
</div>
);
}
API Key Storage
// packages/noodl-editor/src/editor/src/utils/migration/keyStorage.ts
import Store from 'electron-store';
import { safeStorage } from 'electron';
const store = new Store({
name: 'ai-config',
encryptionKey: 'opennoodl-migration' // Additional layer
});
export async function saveApiKey(apiKey: string): Promise<void> {
// Use Electron's safeStorage for OS-level encryption
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey);
store.set('anthropic.apiKey', encrypted.toString('base64'));
} else {
// Fallback to electron-store encryption
store.set('anthropic.apiKey', apiKey);
}
}
export async function getApiKey(): Promise<string | null> {
const stored = store.get('anthropic.apiKey') as string | undefined;
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {
try {
const buffer = Buffer.from(stored, 'base64');
return safeStorage.decryptString(buffer);
} catch {
return null;
}
}
return stored;
}
export async function clearApiKey(): Promise<void> {
store.delete('anthropic.apiKey');
}
export async function testAnthropicKey(apiKey: string): Promise<boolean> {
const Anthropic = require('@anthropic-ai/sdk');
const client = new Anthropic({ apiKey });
try {
// Make a minimal API call to verify the key
await client.messages.create({
model: 'claude-sonnet-4-5-20250514',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }]
});
return true;
} catch (error) {
if (error.status === 401) {
throw new Error('Invalid API key');
}
if (error.status === 403) {
throw new Error('API key does not have required permissions');
}
throw new Error(`API error: ${error.message}`);
}
}
Claude Integration
System Prompt
// packages/noodl-editor/src/editor/src/utils/migration/claudePrompts.ts
export const MIGRATION_SYSTEM_PROMPT = `You are a React migration assistant for OpenNoodl, a visual programming platform. Your job is to migrate React class components from React 17 patterns to React 19.
## Your Task
Convert the provided React code to be compatible with React 19. The code may contain:
- componentWillMount (removed in React 19)
- componentWillReceiveProps (removed in React 19)
- componentWillUpdate (removed in React 19)
- UNSAFE_ prefixed lifecycle methods (removed in React 19)
- String refs (removed in React 19)
- Legacy context API (removed in React 19)
- React.createFactory (removed in React 19)
## Migration Rules
### Lifecycle Methods
1. componentWillMount → Move logic to componentDidMount or constructor
2. componentWillReceiveProps → Use getDerivedStateFromProps (static) or componentDidUpdate
3. componentWillUpdate → Use getSnapshotBeforeUpdate + componentDidUpdate
### String Refs
Convert: ref="myRef" → ref={this.myRef = React.createRef()} or useRef()
### Legacy Context
Convert contextTypes/childContextTypes/getChildContext → React.createContext
### Functional Preference
If the component doesn't use complex state or many lifecycle methods, prefer converting to a functional component with hooks.
## Output Format
You MUST respond with a JSON object in this exact format:
{
"success": true,
"code": "// The migrated code here",
"changes": [
"Converted componentWillMount to useEffect",
"Replaced string ref with useRef"
],
"warnings": [
"Verify the useEffect dependency array is correct"
],
"confidence": 0.85
}
If you cannot migrate the code:
{
"success": false,
"code": null,
"reason": "Explanation of why migration failed",
"suggestion": "What the user could do manually",
"confidence": 0
}
## Rules
1. PRESERVE all existing functionality exactly
2. PRESERVE all comments unless they reference removed APIs
3. ADD comments explaining non-obvious changes
4. DO NOT change prop names or component interfaces
5. DO NOT add new dependencies
6. If confidence < 0.7, explain why in warnings
7. Test the code mentally - would it work?
## Context
This code is from an OpenNoodl project. OpenNoodl uses a custom node system where React components are wrapped. The component may reference:
- this.props.noodlNode - Reference to the Noodl node instance
- this.forceUpdate() - Triggers re-render (still valid in React 19)
- this.setStyle() - Noodl method for styling
- this.getRef() - Noodl method for DOM access`;
export const RETRY_PROMPT_TEMPLATE = `The previous migration attempt failed verification.
Previous attempt result:
{previousError}
Previous code:
\`\`\`javascript
{previousCode}
\`\`\`
Please try a different approach. Consider:
1. Maybe the conversion should stay as a class component instead of functional
2. Check if state management is correct
3. Verify event handlers are bound correctly
4. Ensure refs are used correctly
Provide a new migration with the same JSON format.`;
export const HELP_PROMPT_TEMPLATE = `I attempted to migrate this React component {attempts} times but couldn't produce working code.
Original code:
\`\`\`javascript
{originalCode}
\`\`\`
Attempts and errors:
{attemptHistory}
Please analyze this component and provide:
1. Why it's difficult to migrate automatically
2. Step-by-step manual migration instructions
3. Any gotchas or things to watch out for
4. Example code snippets for the tricky parts
Format your response as helpful documentation, not JSON.`;
Claude Client
// packages/noodl-editor/src/editor/src/utils/migration/claudeClient.ts
import Anthropic from '@anthropic-ai/sdk';
export interface MigrationRequest {
code: string;
issues: MigrationIssue[];
componentName: string;
preferences: AIConfig['preferences'];
previousAttempt?: {
code: string;
error: string;
};
}
export interface MigrationResponse {
success: boolean;
code: string | null;
changes: string[];
warnings: string[];
confidence: number;
reason?: string;
suggestion?: string;
tokensUsed: {
input: number;
output: number;
};
cost: number;
}
export class ClaudeClient {
private client: Anthropic;
private model = 'claude-sonnet-4-5-20250514';
// Pricing per 1M tokens (as of 2024)
private pricing = {
input: 3.00, // $3 per 1M input tokens
output: 15.00 // $15 per 1M output tokens
};
constructor(apiKey: string) {
this.client = new Anthropic({ apiKey });
}
async migrateComponent(request: MigrationRequest): Promise<MigrationResponse> {
const userPrompt = this.buildUserPrompt(request);
const startTime = Date.now();
const response = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: MIGRATION_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }]
});
const tokensUsed = {
input: response.usage.input_tokens,
output: response.usage.output_tokens
};
const cost = this.calculateCost(tokensUsed);
// Parse the response
const content = response.content[0];
if (content.type !== 'text') {
throw new Error('Unexpected response type');
}
try {
const parsed = this.parseResponse(content.text);
return {
...parsed,
tokensUsed,
cost
};
} catch (parseError) {
return {
success: false,
code: null,
changes: [],
warnings: [],
confidence: 0,
reason: 'Failed to parse AI response',
suggestion: content.text.slice(0, 500), // Include raw response for debugging
tokensUsed,
cost
};
}
}
async getHelp(request: HelpRequest): Promise<string> {
const prompt = HELP_PROMPT_TEMPLATE
.replace('{attempts}', String(request.attempts))
.replace('{originalCode}', request.originalCode)
.replace('{attemptHistory}', request.attemptHistory
.map((a, i) => `Attempt ${i + 1}: ${a.error}`)
.join('\n')
);
const response = await this.client.messages.create({
model: this.model,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }]
});
const content = response.content[0];
if (content.type !== 'text') {
throw new Error('Unexpected response type');
}
return content.text;
}
private buildUserPrompt(request: MigrationRequest): string {
let prompt = `Migrate this React component to React 19:\n\n`;
prompt += `Component: ${request.componentName}\n\n`;
prompt += `Issues detected:\n`;
request.issues.forEach(issue => {
prompt += `- ${issue.type} at line ${issue.location.line}: ${issue.description}\n`;
});
prompt += `\nCode:\n\`\`\`javascript\n${request.code}\n\`\`\`\n`;
if (request.preferences.preferFunctional) {
prompt += `\nPreference: Convert to functional component with hooks if clean.\n`;
}
if (request.previousAttempt) {
prompt += `\n--- RETRY ---\n`;
prompt += `Previous attempt failed: ${request.previousAttempt.error}\n`;
prompt += `Previous code:\n\`\`\`javascript\n${request.previousAttempt.code}\n\`\`\`\n`;
prompt += `Please try a different approach.\n`;
}
return prompt;
}
private parseResponse(text: string): Omit<MigrationResponse, 'tokensUsed' | 'cost'> {
// Try to extract JSON from the response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? false,
code: parsed.code ?? null,
changes: parsed.changes ?? [],
warnings: parsed.warnings ?? [],
confidence: parsed.confidence ?? 0,
reason: parsed.reason,
suggestion: parsed.suggestion
};
}
private calculateCost(tokens: { input: number; output: number }): number {
const inputCost = (tokens.input / 1_000_000) * this.pricing.input;
const outputCost = (tokens.output / 1_000_000) * this.pricing.output;
return inputCost + outputCost;
}
estimateCost(codeLength: number): number {
// Rough estimation: ~4 chars per token
const estimatedInputTokens = (codeLength / 4) + 1000; // +1000 for system prompt
const estimatedOutputTokens = (codeLength / 4) * 1.5; // Output usually larger
return this.calculateCost({
input: estimatedInputTokens,
output: estimatedOutputTokens
});
}
}
Budget Controller
// packages/noodl-editor/src/editor/src/models/migration/BudgetController.ts
export interface BudgetState {
maxPerSession: number;
spent: number;
pauseIncrement: number;
nextPauseAt: number;
paused: boolean;
}
export interface BudgetCheckResult {
allowed: boolean;
requiresApproval: boolean;
currentSpent: number;
estimatedNext: number;
wouldExceedMax: boolean;
}
export class BudgetController {
private state: BudgetState;
private onPauseRequired: (state: BudgetState) => Promise<boolean>;
constructor(
config: AIConfig['budget'],
onPauseRequired: (state: BudgetState) => Promise<boolean>
) {
this.state = {
maxPerSession: config.maxPerSession,
spent: 0,
pauseIncrement: config.pauseIncrement,
nextPauseAt: config.pauseIncrement,
paused: false
};
this.onPauseRequired = onPauseRequired;
}
checkBudget(estimatedCost: number): BudgetCheckResult {
const wouldExceedMax = this.state.spent + estimatedCost > this.state.maxPerSession;
const wouldExceedPause = this.state.spent + estimatedCost > this.state.nextPauseAt;
return {
allowed: !wouldExceedMax,
requiresApproval: wouldExceedPause && !this.state.paused,
currentSpent: this.state.spent,
estimatedNext: estimatedCost,
wouldExceedMax
};
}
async requestApproval(estimatedCost: number): Promise<boolean> {
const check = this.checkBudget(estimatedCost);
if (check.wouldExceedMax) {
return false; // Hard limit, can't approve
}
if (check.requiresApproval) {
const approved = await this.onPauseRequired(this.state);
if (approved) {
this.state.nextPauseAt += this.state.pauseIncrement;
}
return approved;
}
return true;
}
recordSpend(amount: number): void {
this.state.spent += amount;
}
getState(): BudgetState {
return { ...this.state };
}
getRemainingBudget(): number {
return Math.max(0, this.state.maxPerSession - this.state.spent);
}
}
Migration Orchestrator
// packages/noodl-editor/src/editor/src/models/migration/AIMigrationOrchestrator.ts
export interface OrchestratorConfig {
maxRetries: number;
minConfidence: number;
verifyMigration: boolean;
}
export interface ComponentMigrationResult {
componentId: string;
componentName: string;
status: 'success' | 'partial' | 'failed' | 'skipped';
migratedCode?: string;
changes: string[];
warnings: string[];
attempts: number;
totalCost: number;
error?: string;
aiSuggestion?: string;
}
export class AIMigrationOrchestrator {
private client: ClaudeClient;
private budget: BudgetController;
private config: OrchestratorConfig;
private aborted = false;
constructor(
apiKey: string,
budgetConfig: AIConfig['budget'],
config: OrchestratorConfig,
onBudgetPause: (state: BudgetState) => Promise<boolean>
) {
this.client = new ClaudeClient(apiKey);
this.budget = new BudgetController(budgetConfig, onBudgetPause);
this.config = config;
}
async migrateComponent(
component: ComponentMigrationInfo,
code: string,
preferences: AIConfig['preferences'],
onProgress: (update: ProgressUpdate) => void,
onDecisionRequired: (request: DecisionRequest) => Promise<Decision>
): Promise<ComponentMigrationResult> {
let attempts = 0;
let totalCost = 0;
let lastError: string | null = null;
let lastCode: string | null = null;
const attemptHistory: AttemptRecord[] = [];
while (attempts < this.config.maxRetries && !this.aborted) {
attempts++;
// Check budget
const estimatedCost = this.client.estimateCost(code.length);
const budgetCheck = this.budget.checkBudget(estimatedCost);
if (!budgetCheck.allowed) {
return {
componentId: component.id,
componentName: component.name,
status: 'failed',
changes: [],
warnings: [],
attempts,
totalCost,
error: 'Budget exceeded'
};
}
if (budgetCheck.requiresApproval) {
const approved = await this.budget.requestApproval(estimatedCost);
if (!approved) {
return {
componentId: component.id,
componentName: component.name,
status: 'skipped',
changes: [],
warnings: ['Migration paused by user'],
attempts,
totalCost
};
}
}
// Attempt migration
onProgress({
phase: 'ai-migrating',
component: component.name,
attempt: attempts,
message: attempts === 1
? 'Analyzing code patterns...'
: `Retry attempt ${attempts}...`
});
const response = await this.client.migrateComponent({
code,
issues: component.issues,
componentName: component.name,
preferences,
previousAttempt: lastError ? { code: lastCode!, error: lastError } : undefined
});
totalCost += response.cost;
this.budget.recordSpend(response.cost);
if (response.success && response.confidence >= this.config.minConfidence) {
// Verify the migration if enabled
if (this.config.verifyMigration) {
const verification = await this.verifyMigration(response.code!, component);
if (!verification.valid) {
lastError = verification.error;
lastCode = response.code;
attemptHistory.push({
code: response.code,
error: verification.error,
cost: response.cost
});
continue;
}
}
return {
componentId: component.id,
componentName: component.name,
status: 'success',
migratedCode: response.code!,
changes: response.changes,
warnings: response.warnings,
attempts,
totalCost
};
}
// Migration failed or low confidence
lastError = response.reason || 'Low confidence migration';
lastCode = response.code;
attemptHistory.push({
code: response.code,
error: lastError,
cost: response.cost
});
}
// All retries exhausted - ask user what to do
const decision = await onDecisionRequired({
componentId: component.id,
componentName: component.name,
attempts,
attemptHistory,
costSpent: totalCost,
retryCost: this.client.estimateCost(code.length)
});
switch (decision.action) {
case 'retry':
// Recursive retry with fresh attempts
return this.migrateComponent(
component, code, preferences, onProgress, onDecisionRequired
);
case 'skip':
return {
componentId: component.id,
componentName: component.name,
status: 'skipped',
changes: [],
warnings: ['Skipped by user after failed attempts'],
attempts,
totalCost
};
case 'getHelp':
const help = await this.client.getHelp({
originalCode: code,
attempts,
attemptHistory
});
totalCost += 0.02; // Approximate cost for help request
return {
componentId: component.id,
componentName: component.name,
status: 'failed',
changes: [],
warnings: attemptHistory.map(a => a.error),
attempts,
totalCost,
aiSuggestion: help
};
case 'manual':
return {
componentId: component.id,
componentName: component.name,
status: 'partial',
migratedCode: lastCode || undefined,
changes: [],
warnings: ['Marked for manual review'],
attempts,
totalCost
};
}
}
private async verifyMigration(
code: string,
component: ComponentMigrationInfo
): Promise<{ valid: boolean; error?: string }> {
// Basic syntax check using Babel
try {
const babel = require('@babel/parser');
babel.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript']
});
} catch (syntaxError) {
return {
valid: false,
error: `Syntax error: ${syntaxError.message}`
};
}
// Check that no forbidden patterns remain
const forbiddenPatterns = [
{ regex: /componentWillMount\s*\(/, name: 'componentWillMount' },
{ regex: /componentWillReceiveProps\s*\(/, name: 'componentWillReceiveProps' },
{ regex: /componentWillUpdate\s*\(/, name: 'componentWillUpdate' },
{ regex: /ref\s*=\s*["'][^"']+["']/, name: 'string ref' },
{ regex: /contextTypes\s*=/, name: 'legacy contextTypes' },
];
for (const pattern of forbiddenPatterns) {
if (pattern.regex.test(code)) {
return {
valid: false,
error: `Code still contains ${pattern.name}`
};
}
}
return { valid: true };
}
abort(): void {
this.aborted = true;
}
getBudgetState(): BudgetState {
return this.budget.getState();
}
}
Budget Approval Dialog
// packages/noodl-editor/src/editor/src/views/migration/BudgetApprovalDialog.tsx
interface BudgetApprovalDialogProps {
state: BudgetState;
onApprove: () => void;
onDeny: () => void;
}
function BudgetApprovalDialog({ state, onApprove, onDeny }: BudgetApprovalDialogProps) {
return (
<Dialog title="Budget Check" size="small">
<div className={css['budget-approval']}>
<div className={css['budget-icon']}>
<WalletIcon size={32} />
</div>
<p>
You've spent <strong>${state.spent.toFixed(2)}</strong> of your
<strong> ${state.maxPerSession.toFixed(2)}</strong> budget.
</p>
<p>
Continue with another <strong>${state.pauseIncrement.toFixed(2)}</strong> allowance?
</p>
<div className={css['budget-bar']}>
<div
className={css['budget-bar__spent']}
style={{ width: `${(state.spent / state.maxPerSession) * 100}%` }}
/>
<div
className={css['budget-bar__pending']}
style={{
left: `${(state.spent / state.maxPerSession) * 100}%`,
width: `${(state.pauseIncrement / state.maxPerSession) * 100}%`
}}
/>
</div>
<div className={css['budget-labels']}>
<span>$0</span>
<span>${state.maxPerSession.toFixed(2)}</span>
</div>
</div>
<DialogActions>
<Button variant="secondary" onClick={onDeny}>
Stop Here
</Button>
<Button variant="primary" onClick={onApprove}>
Continue (+${state.pauseIncrement.toFixed(2)})
</Button>
</DialogActions>
</Dialog>
);
}
Testing Checklist
- API key validation works
- Invalid key shows clear error
- Key is stored encrypted
- Budget controls enforce limits
- Pause dialog appears at increment
- Cost estimates are reasonable
- Claude responses parse correctly
- Retry logic works
- Decision dialog appears after max retries
- "Get Help" returns useful suggestions
- Budget display updates in real-time
- Abort stops migration cleanly