diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js index d8836048..c46271eb 100644 --- a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -206,6 +206,43 @@ describe('AWSSchedulerAdapter', () => { const command = mockSend.mock.calls[0][0]; expect(command.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); }); + + it('should fall back to UpdateScheduleCommand on ConflictException', async () => { + const conflictError = new Error('Schedule already exists'); + conflictError.name = 'ConflictException'; + + mockSend + .mockRejectedValueOnce(conflictError) + .mockResolvedValueOnce({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + const result = await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + expect(result).toEqual({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', + }); + + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend.mock.calls[0][0]._type).toBe('CreateScheduleCommand'); + expect(mockSend.mock.calls[1][0]._type).toBe('UpdateScheduleCommand'); + }); + + it('should rethrow non-conflict errors', async () => { + const otherError = new Error('Access denied'); + otherError.name = 'AccessDeniedException'; + + mockSend.mockRejectedValue(otherError); + + await expect(adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + })).rejects.toThrow('Access denied'); + }); }); describe('deleteSchedule()', () => { diff --git a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js index 732bd48e..a93b2017 100644 --- a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js @@ -210,12 +210,12 @@ describe('LocalSchedulerAdapter', () => { const schedules = await adapter.listSchedules(); expect(schedules).toHaveLength(3); - expect(schedules.map((s) => s.scriptName)).toContain('script-1'); - expect(schedules.map((s) => s.scriptName)).toContain('script-2'); - expect(schedules.map((s) => s.scriptName)).toContain('script-3'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-1'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-2'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-3'); }); - it('should include all schedule properties', async () => { + it('should include all schedule properties in normalized format', async () => { await adapter.createSchedule({ scriptName: 'test-script', cronExpression: '0 0 * * *', @@ -226,14 +226,11 @@ describe('LocalSchedulerAdapter', () => { const schedules = await adapter.listSchedules(); expect(schedules[0]).toMatchObject({ - scriptName: 'test-script', - cronExpression: '0 0 * * *', - timezone: 'America/New_York', - input: { key: 'value' }, - enabled: true, + Name: 'frigg-script-test-script', + State: 'ENABLED', + ScheduleExpression: '0 0 * * *', + ScheduleExpressionTimezone: 'America/New_York', }); - expect(schedules[0]).toHaveProperty('createdAt'); - expect(schedules[0]).toHaveProperty('updatedAt'); }); }); diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js index ec86b84e..dfe64e27 100644 --- a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -60,7 +60,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter { const client = this.getSchedulerClient(); const scheduleName = `frigg-script-${scriptName}`; - const command = new CreateScheduleCommand({ + const scheduleParams = { Name: scheduleName, GroupName: this.scheduleGroupName, ScheduleExpression: cronExpression, @@ -76,13 +76,24 @@ class AWSSchedulerAdapter extends SchedulerAdapter { }), }, State: 'ENABLED', - }); - - const response = await client.send(command); - return { - scheduleArn: response.ScheduleArn, - scheduleName: scheduleName, }; + + try { + const response = await client.send(new CreateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } catch (error) { + if (error.name === 'ConflictException') { + const response = await client.send(new UpdateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } + throw error; + } } async deleteSchedule(scriptName) { diff --git a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js index cc9640ee..7cca0a97 100644 --- a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js @@ -57,7 +57,12 @@ class LocalSchedulerAdapter extends SchedulerAdapter { } async listSchedules() { - return Array.from(this.schedules.values()); + return Array.from(this.schedules.values()).map((schedule) => ({ + Name: `frigg-script-${schedule.scriptName}`, + State: schedule.enabled ? 'ENABLED' : 'DISABLED', + ScheduleExpression: schedule.cronExpression, + ScheduleExpressionTimezone: schedule.timezone, + })); } async getSchedule(scriptName) { diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 6ebeb557..8caf7936 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -1,4 +1,5 @@ const { createScriptRunner } = require('../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); /** * SQS Queue Worker Lambda Handler @@ -12,11 +13,12 @@ async function handler(event) { for (const record of event.Records) { let scriptName; + let executionId; try { const message = JSON.parse(record.body); - ({ scriptName } = message); - const { executionId, trigger, params } = message; + ({ scriptName, executionId } = message); + const { trigger, params } = message; if (!scriptName || !executionId) { throw new Error(`Invalid SQS message: missing scriptName or executionId`); @@ -41,6 +43,25 @@ async function handler(event) { // Only reaches here for unexpected failures (message parse errors, runner construction). // Script execution errors are handled by ScriptRunner and returned as { status: 'FAILED' }. console.error(`Unexpected error processing record:`, error); + + // If we have an executionId, mark the admin process as FAILED + // so the record doesn't stay stuck in a non-terminal state. + if (executionId) { + try { + const commands = createAdminScriptCommands(); + await commands.completeAdminProcess(executionId, { + state: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + } catch (updateError) { + console.error(`Failed to update execution ${executionId} state:`, updateError); + } + } + results.push({ scriptName: scriptName || 'unknown', status: 'FAILED',