Skip to content

Commit f6f0fad

Browse files
fix: deduplicate workflow and job names (#4606)
* fix: deduplicate workflow and job names on import Workflow name validation and job name deduplication now happen centrally inside importWorkflow, fixing AI-generated workflows that couldn't be saved when they had a name collision with existing workflows or duplicate job names. * test: add importWorkflow tests for name deduplication Covers workflow name validation via channel, job name deduplication, graceful fallback when validation fails, and no-op for unique names. * fix: avoid collisions when dedup suffix matches an existing job name Use a Set to track all used names (original + generated) instead of a simple counter, so "Transform 2" is skipped when it already exists. * test: fix mocks to return Promise for async importWorkflow * docs: add changelog entry for workflow name dedup fix * fix: handle importWorkflow rejection and reduce test boilerplate Add .catch() to the importWorkflow promise in WorkflowEditor to prevent unhandled rejections. Extract makeJob, makeWorkflowState, and getJobNames test helpers to reduce repetition in importWorkflow tests. * fix: resolve TypeScript errors for job_id on WorkflowTemplateContext union Extract selectedContextJobId with an `in` type guard to safely access job_id from the WorkflowTemplateContext discriminated union, fixing TS2339 errors and an eslint complex-expression warning. --------- Co-authored-by: Frank Midigo <midigofrank@gmail.com>
1 parent 3ab0f63 commit f6f0fad

9 files changed

Lines changed: 219 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ and this project adheres to
3939

4040
### Fixed
4141

42+
- AI-generated workflows can now be saved when the workflow name collides with
43+
an existing workflow or when jobs have duplicate names
44+
[#4607](https://github.com/OpenFn/lightning/issues/4607)
4245
- Since OTP26, if `SMTP_PROVIDER` is set to `smtp` and `SMTP_TLS` is set to
4346
`true` or `if_available` this would result in TLS-related failures when trying
4447
to send emails. This is now fixed for a limited number of use cases (see

assets/js/collaborative-editor/components/AIAssistantPanel.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ export function AIAssistantPanel({
164164
? 'Connecting...'
165165
: undefined;
166166

167+
const selectedContextJobId =
168+
workflowTemplateContext && 'job_id' in workflowTemplateContext
169+
? workflowTemplateContext.job_id
170+
: undefined;
171+
167172
// Load session list when viewing sessions
168173
useEffect(() => {
169174
if (!isOpen || view !== 'sessions' || !storeSessionType) return;
@@ -177,7 +182,7 @@ export function AIAssistantPanel({
177182
isOpen,
178183
view,
179184
storeSessionType,
180-
workflowTemplateContext?.job_id,
185+
selectedContextJobId,
181186
hasSessionContext,
182187
loadSessionList,
183188
]);
@@ -462,7 +467,7 @@ export function AIAssistantPanel({
462467
disabledMessage={disabledMessage}
463468
selectedStepId={selectedStepId}
464469
selectedRunId={selectedRunId}
465-
selectedJobId={workflowTemplateContext?.job_id ?? null}
470+
selectedJobId={selectedContextJobId ?? null}
466471
/>
467472

468473
{/* About AI Assistant Modal */}

assets/js/collaborative-editor/components/WorkflowEditor.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -393,19 +393,13 @@ export function WorkflowEditor({
393393
*/
394394
const handleImport = useCallback(
395395
(workflowState: YAMLWorkflowState) => {
396-
// Validate workflow name asynchronously, but proceed with import regardless
397-
workflowStore
398-
.validateWorkflowName(workflowState)
399-
.then(validatedState => {
400-
void workflowStore.importWorkflow(validatedState);
396+
void workflowStore
397+
.importWorkflow(workflowState)
398+
.then(() => {
401399
flowEvents.dispatch('fit-view');
402-
return;
403400
})
404401
.catch((error: unknown) => {
405-
// If validation fails, import with original state
406-
// Server will handle any name conflicts on save
407-
console.warn('Workflow name validation failed, proceeding:', error);
408-
workflowStore.importWorkflow(workflowState);
402+
console.error('Failed to import workflow:', error);
409403
});
410404
},
411405
[workflowStore]

assets/js/collaborative-editor/hooks/useAIWorkflowApplications.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function useAIWorkflowApplications({
123123
currentUserId: string | undefined;
124124
aiMode: AIModeResult | null;
125125
workflowActions: {
126-
importWorkflow: (state: YAMLWorkflowState) => void;
126+
importWorkflow: (state: YAMLWorkflowState) => Promise<void>;
127127
startApplyingWorkflow: (messageId: string) => Promise<boolean>;
128128
doneApplyingWorkflow: (messageId: string) => Promise<void>;
129129
startApplyingJobCode: (messageId: string) => Promise<boolean>;
@@ -198,7 +198,7 @@ export function useAIWorkflowApplications({
198198
extractJobCredentials(jobs)
199199
);
200200

201-
importWorkflow(workflowStateWithCreds);
201+
await importWorkflow(workflowStateWithCreds);
202202
} catch (error) {
203203
console.error('[AI Assistant] Failed to apply workflow:', error);
204204

assets/js/collaborative-editor/stores/createWorkflowStore.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,28 +1540,91 @@ export const createWorkflowStore = () => {
15401540
}
15411541
};
15421542

1543+
/**
1544+
* Deduplicate job names within a workflow state.
1545+
*
1546+
* If multiple jobs share the same name, appends " 2", " 3", etc.
1547+
* to make each name unique. The first occurrence keeps its original name.
1548+
* Skips suffixes that collide with existing names in the workflow.
1549+
*/
1550+
const deduplicateJobNames = (
1551+
workflowState: YAMLWorkflowState
1552+
): YAMLWorkflowState => {
1553+
const allOriginalNames = new Set(workflowState.jobs.map(j => j.name));
1554+
const usedNames = new Set<string>();
1555+
let hasDuplicates = false;
1556+
1557+
const jobs = workflowState.jobs.map(job => {
1558+
if (!usedNames.has(job.name)) {
1559+
usedNames.add(job.name);
1560+
return job;
1561+
}
1562+
1563+
hasDuplicates = true;
1564+
let counter = 2;
1565+
while (
1566+
usedNames.has(`${job.name} ${counter}`) ||
1567+
allOriginalNames.has(`${job.name} ${counter}`)
1568+
) {
1569+
counter++;
1570+
}
1571+
const newName = `${job.name} ${counter}`;
1572+
usedNames.add(newName);
1573+
return { ...job, name: newName };
1574+
});
1575+
1576+
if (hasDuplicates) {
1577+
logger.info('Deduplicated job names', {
1578+
original: workflowState.jobs.map(j => j.name),
1579+
deduplicated: jobs.map(j => j.name),
1580+
});
1581+
return { ...workflowState, jobs };
1582+
}
1583+
1584+
return workflowState;
1585+
};
1586+
15431587
/**
15441588
* Import workflow from YAML WorkflowState
15451589
*
1590+
* Validates workflow name uniqueness via the server and deduplicates
1591+
* job names client-side before writing to Y.Doc. This is the single
1592+
* central place for name validation on import, regardless of how the
1593+
* import was triggered (manual import, template, or AI generation).
1594+
*
15461595
* Uses Pattern 1 (Y.Doc → Observer → Immer):
15471596
* - Single transact() for atomic bulk updates
15481597
* - Observers automatically sync Immer state
15491598
* - No manual notify() calls needed
15501599
*
15511600
* @param workflowState - Parsed YAML workflow state
15521601
*/
1553-
const importWorkflow = (workflowState: YAMLWorkflowState) => {
1602+
const importWorkflow = async (workflowState: YAMLWorkflowState) => {
15541603
const ydoc = ensureYDoc();
15551604

1605+
// Validate workflow name uniqueness via server
1606+
let validatedState: YAMLWorkflowState;
1607+
try {
1608+
validatedState = await validateWorkflowName(workflowState);
1609+
} catch (error) {
1610+
// If validation fails, proceed with original name
1611+
// Server will handle any name conflicts on save
1612+
logger.warn('Workflow name validation failed, proceeding', error);
1613+
validatedState = workflowState;
1614+
}
1615+
1616+
// Deduplicate job names client-side
1617+
validatedState = deduplicateJobNames(validatedState);
1618+
15561619
try {
15571620
// Use adapter to apply transformations and update Y.Doc
1558-
YAMLStateToYDoc.applyToYDoc(ydoc, workflowState);
1621+
YAMLStateToYDoc.applyToYDoc(ydoc, validatedState);
15591622

15601623
logger.info('Workflow imported successfully', {
1561-
workflowId: workflowState.id,
1562-
jobs: workflowState.jobs.length,
1563-
triggers: workflowState.triggers.length,
1564-
edges: workflowState.edges.length,
1624+
workflowId: validatedState.id,
1625+
jobs: validatedState.jobs.length,
1626+
triggers: validatedState.triggers.length,
1627+
edges: validatedState.edges.length,
15651628
});
15661629

15671630
// Note: Observers will automatically trigger Immer updates and notify React

assets/test/collaborative-editor/components/WorkflowEditor.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({
238238
}),
239239
useWorkflowStoreContext: () => ({
240240
validateWorkflowName: vi.fn(),
241-
importWorkflow: vi.fn(),
241+
importWorkflow: vi.fn().mockResolvedValue(undefined),
242242
}),
243243
useWorkflowActions: () => ({
244244
saveWorkflow: vi.fn(),

assets/test/collaborative-editor/hooks/useAIWorkflowApplications.autoApply.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ vi.mock('../../../js/yaml/util', () => ({
6868

6969
describe('useAIWorkflowApplications - Auto-Application', () => {
7070
// Mock functions
71-
const mockImportWorkflow = vi.fn();
71+
const mockImportWorkflow = vi.fn(() => Promise.resolve());
7272
const mockStartApplyingWorkflow = vi.fn(() => Promise.resolve(true));
7373
const mockDoneApplyingWorkflow = vi.fn(() => Promise.resolve());
7474
const mockStartApplyingJobCode = vi.fn(() => Promise.resolve(true));

assets/test/collaborative-editor/hooks/useAIWorkflowApplications.workflow.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ vi.mock('../../../js/collaborative-editor/lib/notifications', () => ({
8383

8484
describe('useAIWorkflowApplications - handleApplyWorkflow', () => {
8585
// Mock functions
86-
const mockImportWorkflow = vi.fn();
86+
const mockImportWorkflow = vi.fn(() => Promise.resolve());
8787
const mockStartApplyingWorkflow = vi.fn(() => Promise.resolve(true));
8888
const mockDoneApplyingWorkflow = vi.fn(() => Promise.resolve());
8989
const mockStartApplyingJobCode = vi.fn(() => Promise.resolve(true));

assets/test/collaborative-editor/stores/createWorkflowStore.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ChannelRequestError } from '../../../js/collaborative-editor/lib/errors
2020
import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore';
2121
import type { WorkflowStoreInstance } from '../../../js/collaborative-editor/stores/createWorkflowStore';
2222
import type { Session } from '../../../js/collaborative-editor/types/session';
23+
import type { WorkflowState as YAMLWorkflowState } from '../../../js/yaml/types';
2324
import {
2425
createMockChannelPushOk,
2526
createMockChannelPushError,
@@ -1280,3 +1281,133 @@ describe('WorkflowStore - AI Workflow Apply Coordination', () => {
12801281
).resolves.not.toThrow();
12811282
});
12821283
});
1284+
1285+
describe('WorkflowStore - importWorkflow', () => {
1286+
let store: WorkflowStoreInstance;
1287+
let ydoc: Session.WorkflowDoc;
1288+
let mockChannel: MockPhoenixChannel;
1289+
let mockProvider: PhoenixChannelProvider & { channel: MockPhoenixChannel };
1290+
1291+
const makeJob = (id: string, name: string) => ({
1292+
id,
1293+
name,
1294+
adaptor: '@openfn/language-http@latest',
1295+
body: 'fn(s => s)',
1296+
project_credential_id: null,
1297+
keychain_credential_id: null,
1298+
});
1299+
1300+
const makeWorkflowState = (
1301+
overrides: Partial<YAMLWorkflowState> = {}
1302+
): YAMLWorkflowState => ({
1303+
id: 'wf-1',
1304+
name: 'My Workflow',
1305+
jobs: [],
1306+
triggers: [],
1307+
edges: [],
1308+
positions: null,
1309+
...overrides,
1310+
});
1311+
1312+
const getJobNames = () => {
1313+
const jobsArray = ydoc.getArray('jobs');
1314+
return Array.from({ length: jobsArray.length }, (_, i) =>
1315+
(jobsArray.get(i) as Y.Map<unknown>).get('name')
1316+
);
1317+
};
1318+
1319+
beforeEach(() => {
1320+
store = createWorkflowStore();
1321+
ydoc = new Y.Doc() as Session.WorkflowDoc;
1322+
1323+
ydoc.getArray('jobs');
1324+
ydoc.getMap('workflow');
1325+
ydoc.getArray('triggers');
1326+
ydoc.getArray('edges');
1327+
ydoc.getMap('positions');
1328+
1329+
mockChannel = createMockPhoenixChannel('workflow:test');
1330+
1331+
mockProvider = {
1332+
channel: mockChannel,
1333+
synced: true,
1334+
awareness: null,
1335+
doc: ydoc,
1336+
} as unknown as PhoenixChannelProvider & { channel: MockPhoenixChannel };
1337+
1338+
store.connect(ydoc, mockProvider);
1339+
});
1340+
1341+
test('deduplicates job names when multiple jobs share the same name', async () => {
1342+
mockChannel.push = createMockChannelPushOk({
1343+
workflow: { name: 'My Workflow' },
1344+
}) as typeof mockChannel.push;
1345+
1346+
await store.importWorkflow(
1347+
makeWorkflowState({
1348+
jobs: [
1349+
makeJob('j1', 'Transform'),
1350+
makeJob('j2', 'Transform'),
1351+
makeJob('j3', 'Transform'),
1352+
],
1353+
})
1354+
);
1355+
1356+
expect(getJobNames()).toEqual(['Transform', 'Transform 2', 'Transform 3']);
1357+
});
1358+
1359+
test('skips suffixes that collide with existing job names', async () => {
1360+
mockChannel.push = createMockChannelPushOk({
1361+
workflow: { name: 'My Workflow' },
1362+
}) as typeof mockChannel.push;
1363+
1364+
await store.importWorkflow(
1365+
makeWorkflowState({
1366+
jobs: [
1367+
makeJob('j1', 'Transform'),
1368+
makeJob('j2', 'Transform'),
1369+
makeJob('j3', 'Transform 2'),
1370+
],
1371+
})
1372+
);
1373+
1374+
// "Transform 2" is taken, so the duplicate skips to "Transform 3"
1375+
expect(getJobNames()).toEqual(['Transform', 'Transform 3', 'Transform 2']);
1376+
});
1377+
1378+
test('leaves unique job names unchanged', async () => {
1379+
mockChannel.push = createMockChannelPushOk({
1380+
workflow: { name: 'My Workflow' },
1381+
}) as typeof mockChannel.push;
1382+
1383+
await store.importWorkflow(
1384+
makeWorkflowState({
1385+
jobs: [makeJob('j1', 'Fetch Data'), makeJob('j2', 'Transform')],
1386+
})
1387+
);
1388+
1389+
expect(getJobNames()).toEqual(['Fetch Data', 'Transform']);
1390+
});
1391+
1392+
test('validates workflow name via channel', async () => {
1393+
// Server returns deduplicated name
1394+
mockChannel.push = createMockChannelPushOk({
1395+
workflow: { name: 'My Workflow 1' },
1396+
}) as typeof mockChannel.push;
1397+
1398+
await store.importWorkflow(makeWorkflowState());
1399+
1400+
expect(ydoc.getMap('workflow').get('name')).toBe('My Workflow 1');
1401+
});
1402+
1403+
test('proceeds with original name when validation fails', async () => {
1404+
mockChannel.push = createMockChannelPushError(
1405+
'server_error',
1406+
{}
1407+
) as typeof mockChannel.push;
1408+
1409+
await store.importWorkflow(makeWorkflowState());
1410+
1411+
expect(ydoc.getMap('workflow').get('name')).toBe('My Workflow');
1412+
});
1413+
});

0 commit comments

Comments
 (0)