@friggframework/admin-scripts 2.0.0--canary.545.b4ca16d.0 → 2.0.0--canary.517.8eaf5df.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +277 -0
  2. package/index.js +23 -6
  3. package/package.json +6 -6
  4. package/src/adapters/__tests__/aws-scheduler-adapter.test.js +106 -45
  5. package/src/adapters/__tests__/local-scheduler-adapter.test.js +24 -10
  6. package/src/adapters/__tests__/scheduler-adapter-factory.test.js +30 -9
  7. package/src/adapters/__tests__/scheduler-adapter.test.js +6 -2
  8. package/src/adapters/aws-scheduler-adapter.js +55 -28
  9. package/src/adapters/local-scheduler-adapter.js +2 -2
  10. package/src/adapters/scheduler-adapter-factory.js +3 -1
  11. package/src/adapters/scheduler-adapter.js +9 -3
  12. package/src/application/__tests__/admin-frigg-commands.test.js +73 -30
  13. package/src/application/__tests__/admin-script-base.test.js +23 -6
  14. package/src/application/__tests__/script-factory.test.js +30 -6
  15. package/src/application/__tests__/script-runner.test.js +113 -24
  16. package/src/application/__tests__/validate-script-input.test.js +54 -15
  17. package/src/application/admin-frigg-commands.js +21 -9
  18. package/src/application/admin-script-base.js +3 -2
  19. package/src/application/script-factory.js +3 -1
  20. package/src/application/script-runner.js +90 -48
  21. package/src/application/use-cases/__tests__/delete-schedule-use-case.test.js +16 -7
  22. package/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js +9 -4
  23. package/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js +21 -10
  24. package/src/application/use-cases/delete-schedule-use-case.js +6 -4
  25. package/src/application/use-cases/get-effective-schedule-use-case.js +3 -1
  26. package/src/application/use-cases/index.js +3 -1
  27. package/src/application/use-cases/upsert-schedule-use-case.js +30 -11
  28. package/src/application/validate-script-input.js +10 -3
  29. package/src/builtins/__tests__/integration-health-check.test.js +232 -127
  30. package/src/builtins/__tests__/oauth-token-refresh.test.js +128 -75
  31. package/src/builtins/index.js +1 -4
  32. package/src/builtins/integration-health-check.js +63 -30
  33. package/src/builtins/oauth-token-refresh.js +64 -29
  34. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +9 -3
  35. package/src/infrastructure/__tests__/admin-script-router.test.js +125 -52
  36. package/src/infrastructure/admin-auth-middleware.js +3 -1
  37. package/src/infrastructure/admin-script-router.js +129 -14
  38. package/src/infrastructure/bootstrap.js +87 -0
  39. package/src/infrastructure/script-executor-handler.js +119 -52
  40. package/src/application/__tests__/dry-run-http-interceptor.test.js +0 -313
  41. package/src/application/__tests__/dry-run-repository-wrapper.test.js +0 -257
  42. package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
  43. package/src/application/dry-run-http-interceptor.js +0 -296
  44. package/src/application/dry-run-repository-wrapper.js +0 -261
  45. package/src/application/schedule-management-use-case.js +0 -230
@@ -1,6 +1,8 @@
1
1
  const { getScriptFactory } = require('./script-factory');
2
2
  const { createAdminScriptContext } = require('./admin-frigg-commands');
3
- const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands');
3
+ const {
4
+ createAdminScriptCommands,
5
+ } = require('@friggframework/core/application/commands/admin-script-commands');
4
6
 
5
7
  /**
6
8
  * Script Runner
@@ -31,10 +33,17 @@ class ScriptRunner {
31
33
  * Pass this when resuming a queued execution to continue using the same execution record.
32
34
  */
33
35
  async execute(scriptName, params = {}, options = {}) {
34
- const { trigger, audit = {}, executionId: existingExecutionId } = options;
36
+ const {
37
+ trigger,
38
+ audit = {},
39
+ executionId: existingExecutionId,
40
+ parentExecutionId,
41
+ } = options;
35
42
 
36
43
  if (!trigger) {
37
- throw new Error('options.trigger is required (MANUAL | SCHEDULED | QUEUE)');
44
+ throw new Error(
45
+ 'options.trigger is required (MANUAL | SCHEDULED | QUEUE)'
46
+ );
38
47
  }
39
48
 
40
49
  // Get script class
@@ -42,7 +51,10 @@ class ScriptRunner {
42
51
  const definition = scriptClass.Definition;
43
52
 
44
53
  // Validate integrationFactory requirement
45
- if (definition.config?.requireIntegrationInstance && !this.integrationFactory) {
54
+ if (
55
+ definition.config?.requireIntegrationInstance &&
56
+ !this.integrationFactory
57
+ ) {
46
58
  throw new Error(
47
59
  `Script "${scriptName}" requires integrationFactory but none was provided`
48
60
  );
@@ -59,21 +71,30 @@ class ScriptRunner {
59
71
  mode: options.mode || 'async',
60
72
  input: params,
61
73
  audit,
74
+ parentExecutionId,
62
75
  });
76
+ // Commands return an error object (never throw) — fail loudly rather
77
+ // than tracking an `undefined` execution id.
78
+ if (execution.error) {
79
+ throw new Error(
80
+ execution.reason || 'Failed to create admin process record'
81
+ );
82
+ }
63
83
  executionId = execution.id;
64
84
  }
65
85
 
66
86
  const startTime = new Date();
67
87
 
88
+ // Created up front so collected logs can be persisted on both paths.
89
+ const context = createAdminScriptContext({
90
+ executionId,
91
+ integrationFactory: this.integrationFactory,
92
+ });
93
+
94
+ let output;
68
95
  try {
69
96
  await this.commands.updateAdminProcessState(executionId, 'RUNNING');
70
97
 
71
- // Create context for the script (facade over repositories, queue, logging)
72
- const context = createAdminScriptContext({
73
- executionId,
74
- integrationFactory: this.integrationFactory,
75
- });
76
-
77
98
  // Create script instance with context injected via constructor
78
99
  const script = this.scriptFactory.createInstance(scriptName, {
79
100
  context,
@@ -82,46 +103,33 @@ class ScriptRunner {
82
103
  });
83
104
 
84
105
  // Execute the script
85
- const output = await script.execute(params);
86
-
87
- // Calculate metrics
88
- const endTime = new Date();
89
- const durationMs = endTime - startTime;
90
-
91
- await this.commands.completeAdminProcess(executionId, {
92
- state: 'COMPLETED',
93
- output,
94
- metrics: {
95
- startTime: startTime.toISOString(),
96
- endTime: endTime.toISOString(),
97
- durationMs,
98
- },
99
- });
100
-
101
- return {
102
- executionId,
103
- status: 'COMPLETED',
104
- scriptName,
105
- output,
106
- metrics: { durationMs },
107
- };
106
+ output = await script.execute(params);
108
107
  } catch (error) {
109
- const endTime = new Date();
110
- const durationMs = endTime - startTime;
108
+ const durationMs = new Date() - startTime;
111
109
 
112
- await this.commands.completeAdminProcess(executionId, {
113
- state: 'FAILED',
114
- error: {
115
- name: error.name,
116
- message: error.message,
117
- stack: error.stack,
118
- },
119
- metrics: {
120
- startTime: startTime.toISOString(),
121
- endTime: endTime.toISOString(),
122
- durationMs,
123
- },
124
- });
110
+ const completion = await this.commands.completeAdminProcess(
111
+ executionId,
112
+ {
113
+ state: 'FAILED',
114
+ error: {
115
+ name: error.name,
116
+ message: error.message,
117
+ stack: error.stack,
118
+ },
119
+ metrics: {
120
+ startTime: startTime.toISOString(),
121
+ endTime: new Date().toISOString(),
122
+ durationMs,
123
+ },
124
+ logs: context.getLogs(),
125
+ }
126
+ );
127
+ if (completion?.error) {
128
+ console.error(
129
+ `Failed to persist FAILED state for execution ${executionId}:`,
130
+ completion.reason
131
+ );
132
+ }
125
133
 
126
134
  return {
127
135
  executionId,
@@ -134,6 +142,40 @@ class ScriptRunner {
134
142
  metrics: { durationMs },
135
143
  };
136
144
  }
145
+
146
+ // Script succeeded. Persist completion OUTSIDE the try above so a
147
+ // persistence failure here is never misreported as a script failure.
148
+ const durationMs = new Date() - startTime;
149
+ const result = {
150
+ executionId,
151
+ status: 'COMPLETED',
152
+ scriptName,
153
+ output,
154
+ metrics: { durationMs },
155
+ };
156
+
157
+ const completion = await this.commands.completeAdminProcess(
158
+ executionId,
159
+ {
160
+ state: 'COMPLETED',
161
+ output,
162
+ metrics: {
163
+ startTime: startTime.toISOString(),
164
+ endTime: new Date().toISOString(),
165
+ durationMs,
166
+ },
167
+ logs: context.getLogs(),
168
+ }
169
+ );
170
+ if (completion?.error) {
171
+ console.error(
172
+ `Script "${scriptName}" ran successfully but persisting COMPLETED state failed for execution ${executionId}:`,
173
+ completion.reason
174
+ );
175
+ result.stateUpdateFailed = true;
176
+ }
177
+
178
+ return result;
137
179
  }
138
180
  }
139
181
 
@@ -31,7 +31,8 @@ describe('DeleteScheduleUseCase', () => {
31
31
  it('should delete schedule and cleanup external scheduler', async () => {
32
32
  const deletedSchedule = {
33
33
  scriptName: 'test-script',
34
- externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
34
+ externalScheduleId:
35
+ 'arn:aws:scheduler:us-east-1:123:schedule/test',
35
36
  };
36
37
 
37
38
  mockScriptFactory.has.mockReturnValue(true);
@@ -47,7 +48,9 @@ describe('DeleteScheduleUseCase', () => {
47
48
  expect(result.success).toBe(true);
48
49
  expect(result.deletedCount).toBe(1);
49
50
  expect(result.message).toBe('Schedule override removed');
50
- expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
51
+ expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith(
52
+ 'test-script'
53
+ );
51
54
  });
52
55
 
53
56
  it('should not call scheduler when no external rule exists', async () => {
@@ -71,7 +74,8 @@ describe('DeleteScheduleUseCase', () => {
71
74
  deletedCount: 1,
72
75
  deleted: {
73
76
  scriptName: 'test-script',
74
- externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
77
+ externalScheduleId:
78
+ 'arn:aws:scheduler:us-east-1:123:schedule/test',
75
79
  },
76
80
  });
77
81
  mockSchedulerAdapter.deleteSchedule.mockRejectedValue(
@@ -105,13 +109,17 @@ describe('DeleteScheduleUseCase', () => {
105
109
  expect(result.effectiveSchedule.source).toBe('definition');
106
110
  expect(result.effectiveSchedule.enabled).toBe(true);
107
111
  expect(result.effectiveSchedule.cronExpression).toBe('0 6 * * *');
108
- expect(result.effectiveSchedule.timezone).toBe('America/Los_Angeles');
112
+ expect(result.effectiveSchedule.timezone).toBe(
113
+ 'America/Los_Angeles'
114
+ );
109
115
  });
110
116
 
111
117
  it('should default timezone to UTC when not in definition', async () => {
112
118
  mockScriptFactory.has.mockReturnValue(true);
113
119
  mockScriptFactory.get.mockReturnValue({
114
- Definition: { schedule: { enabled: true, cronExpression: '0 6 * * *' } },
120
+ Definition: {
121
+ schedule: { enabled: true, cronExpression: '0 6 * * *' },
122
+ },
115
123
  });
116
124
  mockCommands.deleteSchedule.mockResolvedValue({
117
125
  deletedCount: 1,
@@ -155,8 +163,9 @@ describe('DeleteScheduleUseCase', () => {
155
163
  it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
156
164
  mockScriptFactory.has.mockReturnValue(false);
157
165
 
158
- await expect(useCase.execute('non-existent'))
159
- .rejects.toThrow('Script "non-existent" not found');
166
+ await expect(useCase.execute('non-existent')).rejects.toThrow(
167
+ 'Script "non-existent" not found'
168
+ );
160
169
 
161
170
  try {
162
171
  await useCase.execute('non-existent');
@@ -1,4 +1,6 @@
1
- const { GetEffectiveScheduleUseCase } = require('../get-effective-schedule-use-case');
1
+ const {
2
+ GetEffectiveScheduleUseCase,
3
+ } = require('../get-effective-schedule-use-case');
2
4
 
3
5
  describe('GetEffectiveScheduleUseCase', () => {
4
6
  let useCase;
@@ -64,7 +66,9 @@ describe('GetEffectiveScheduleUseCase', () => {
64
66
  it('should default timezone to UTC when not specified in definition', async () => {
65
67
  mockScriptFactory.has.mockReturnValue(true);
66
68
  mockScriptFactory.get.mockReturnValue({
67
- Definition: { schedule: { enabled: true, cronExpression: '0 12 * * *' } },
69
+ Definition: {
70
+ schedule: { enabled: true, cronExpression: '0 12 * * *' },
71
+ },
68
72
  });
69
73
  mockCommands.getScheduleByScriptName.mockResolvedValue(null);
70
74
 
@@ -101,8 +105,9 @@ describe('GetEffectiveScheduleUseCase', () => {
101
105
  it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
102
106
  mockScriptFactory.has.mockReturnValue(false);
103
107
 
104
- await expect(useCase.execute('non-existent'))
105
- .rejects.toThrow('Script "non-existent" not found');
108
+ await expect(useCase.execute('non-existent')).rejects.toThrow(
109
+ 'Script "non-existent" not found'
110
+ );
106
111
 
107
112
  try {
108
113
  await useCase.execute('non-existent');
@@ -46,7 +46,8 @@ describe('UpsertScheduleUseCase', () => {
46
46
  });
47
47
  mockCommands.updateScheduleExternalInfo.mockResolvedValue({
48
48
  ...savedSchedule,
49
- externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
49
+ externalScheduleId:
50
+ 'arn:aws:scheduler:us-east-1:123:schedule/test',
50
51
  });
51
52
 
52
53
  const result = await useCase.execute('test-script', {
@@ -96,7 +97,8 @@ describe('UpsertScheduleUseCase', () => {
96
97
  enabled: false,
97
98
  cronExpression: null,
98
99
  timezone: 'UTC',
99
- externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test',
100
+ externalScheduleId:
101
+ 'arn:aws:scheduler:us-east-1:123:schedule/test',
100
102
  };
101
103
 
102
104
  mockScriptFactory.has.mockReturnValue(true);
@@ -112,7 +114,9 @@ describe('UpsertScheduleUseCase', () => {
112
114
  });
113
115
 
114
116
  expect(result.success).toBe(true);
115
- expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
117
+ expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith(
118
+ 'test-script'
119
+ );
116
120
  });
117
121
 
118
122
  it('should handle scheduler errors gracefully with warning', async () => {
@@ -142,8 +146,9 @@ describe('UpsertScheduleUseCase', () => {
142
146
  it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => {
143
147
  mockScriptFactory.has.mockReturnValue(false);
144
148
 
145
- await expect(useCase.execute('non-existent', { enabled: true }))
146
- .rejects.toThrow('Script "non-existent" not found');
149
+ await expect(
150
+ useCase.execute('non-existent', { enabled: true })
151
+ ).rejects.toThrow('Script "non-existent" not found');
147
152
 
148
153
  try {
149
154
  await useCase.execute('non-existent', { enabled: true });
@@ -155,8 +160,9 @@ describe('UpsertScheduleUseCase', () => {
155
160
  it('should throw INVALID_INPUT error when enabled is not a boolean', async () => {
156
161
  mockScriptFactory.has.mockReturnValue(true);
157
162
 
158
- await expect(useCase.execute('test-script', { enabled: 'yes' }))
159
- .rejects.toThrow('enabled must be a boolean');
163
+ await expect(
164
+ useCase.execute('test-script', { enabled: 'yes' })
165
+ ).rejects.toThrow('enabled must be a boolean');
160
166
 
161
167
  try {
162
168
  await useCase.execute('test-script', { enabled: 'yes' });
@@ -168,8 +174,11 @@ describe('UpsertScheduleUseCase', () => {
168
174
  it('should throw INVALID_INPUT error when enabled without cronExpression', async () => {
169
175
  mockScriptFactory.has.mockReturnValue(true);
170
176
 
171
- await expect(useCase.execute('test-script', { enabled: true }))
172
- .rejects.toThrow('cronExpression is required when enabled is true');
177
+ await expect(
178
+ useCase.execute('test-script', { enabled: true })
179
+ ).rejects.toThrow(
180
+ 'cronExpression is required when enabled is true'
181
+ );
173
182
 
174
183
  try {
175
184
  await useCase.execute('test-script', { enabled: true });
@@ -187,7 +196,9 @@ describe('UpsertScheduleUseCase', () => {
187
196
  timezone: 'UTC',
188
197
  });
189
198
 
190
- const result = await useCase.execute('test-script', { enabled: false });
199
+ const result = await useCase.execute('test-script', {
200
+ enabled: false,
201
+ });
191
202
 
192
203
  expect(result.success).toBe(true);
193
204
  expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({
@@ -31,14 +31,16 @@ class DeleteScheduleUseCase {
31
31
  );
32
32
 
33
33
  // Determine effective schedule after deletion
34
- const effectiveSchedule = this._getEffectiveScheduleAfterDeletion(scriptName);
34
+ const effectiveSchedule =
35
+ this._getEffectiveScheduleAfterDeletion(scriptName);
35
36
 
36
37
  return {
37
38
  success: true,
38
39
  deletedCount: deleteResult.deletedCount,
39
- message: deleteResult.deletedCount > 0
40
- ? 'Schedule override removed'
41
- : 'No schedule override found',
40
+ message:
41
+ deleteResult.deletedCount > 0
42
+ ? 'Schedule override removed'
43
+ : 'No schedule override found',
42
44
  effectiveSchedule,
43
45
  ...(schedulerWarning && { schedulerWarning }),
44
46
  };
@@ -23,7 +23,9 @@ class GetEffectiveScheduleUseCase {
23
23
  this._validateScriptExists(scriptName);
24
24
 
25
25
  // Priority 1: Database override
26
- const dbSchedule = await this.commands.getScheduleByScriptName(scriptName);
26
+ const dbSchedule = await this.commands.getScheduleByScriptName(
27
+ scriptName
28
+ );
27
29
  if (dbSchedule) {
28
30
  return {
29
31
  source: 'database',
@@ -7,7 +7,9 @@
7
7
  * - DeleteScheduleUseCase: Delete schedule with scheduler cleanup
8
8
  */
9
9
 
10
- const { GetEffectiveScheduleUseCase } = require('./get-effective-schedule-use-case');
10
+ const {
11
+ GetEffectiveScheduleUseCase,
12
+ } = require('./get-effective-schedule-use-case');
11
13
  const { UpsertScheduleUseCase } = require('./upsert-schedule-use-case');
12
14
  const { DeleteScheduleUseCase } = require('./delete-schedule-use-case');
13
15
 
@@ -47,10 +47,16 @@ class UpsertScheduleUseCase {
47
47
  success: true,
48
48
  schedule: {
49
49
  ...schedule,
50
- externalScheduleId: schedulerResult.externalScheduleId || schedule.externalScheduleId,
51
- externalScheduleName: schedulerResult.externalScheduleName || schedule.externalScheduleName,
50
+ externalScheduleId:
51
+ schedulerResult.externalScheduleId ||
52
+ schedule.externalScheduleId,
53
+ externalScheduleName:
54
+ schedulerResult.externalScheduleName ||
55
+ schedule.externalScheduleName,
52
56
  },
53
- ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }),
57
+ ...(schedulerResult.warning && {
58
+ schedulerWarning: schedulerResult.warning,
59
+ }),
54
60
  };
55
61
  }
56
62
 
@@ -76,7 +82,9 @@ class UpsertScheduleUseCase {
76
82
  }
77
83
 
78
84
  if (enabled && !cronExpression) {
79
- const error = new Error('cronExpression is required when enabled is true');
85
+ const error = new Error(
86
+ 'cronExpression is required when enabled is true'
87
+ );
80
88
  error.code = 'INVALID_INPUT';
81
89
  throw error;
82
90
  }
@@ -87,17 +95,28 @@ class UpsertScheduleUseCase {
87
95
  * Abstracts AWS EventBridge or other scheduler providers
88
96
  * @private
89
97
  */
90
- async _syncExternalScheduler(scriptName, enabled, cronExpression, timezone, existingId) {
91
- const result = { externalScheduleId: null, externalScheduleName: null, warning: null };
98
+ async _syncExternalScheduler(
99
+ scriptName,
100
+ enabled,
101
+ cronExpression,
102
+ timezone,
103
+ existingId
104
+ ) {
105
+ const result = {
106
+ externalScheduleId: null,
107
+ externalScheduleName: null,
108
+ warning: null,
109
+ };
92
110
 
93
111
  try {
94
112
  if (enabled && cronExpression) {
95
113
  // Create/update external schedule
96
- const schedulerInfo = await this.schedulerAdapter.createSchedule({
97
- scriptName,
98
- cronExpression,
99
- timezone: timezone || 'UTC',
100
- });
114
+ const schedulerInfo =
115
+ await this.schedulerAdapter.createSchedule({
116
+ scriptName,
117
+ cronExpression,
118
+ timezone: timezone || 'UTC',
119
+ });
101
120
 
102
121
  if (schedulerInfo?.scheduleArn) {
103
122
  await this.commands.updateScheduleExternalInfo(scriptName, {
@@ -27,7 +27,8 @@ function validateScriptInput(scriptFactory, scriptName, params = {}) {
27
27
  name: definition.name,
28
28
  version: definition.version,
29
29
  description: definition.description,
30
- requireIntegrationInstance: definition.config?.requireIntegrationInstance || false,
30
+ requireIntegrationInstance:
31
+ definition.config?.requireIntegrationInstance || false,
31
32
  },
32
33
  input: params,
33
34
  inputSchema: definition.inputSchema || null,
@@ -91,7 +92,10 @@ function validateType(key, value, schema) {
91
92
  const expectedType = schema.type;
92
93
  if (!expectedType) return null;
93
94
 
94
- if (expectedType === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) {
95
+ if (
96
+ expectedType === 'integer' &&
97
+ (typeof value !== 'number' || !Number.isInteger(value))
98
+ ) {
95
99
  return `Parameter "${key}" must be an integer`;
96
100
  }
97
101
  if (expectedType === 'number' && typeof value !== 'number') {
@@ -106,7 +110,10 @@ function validateType(key, value, schema) {
106
110
  if (expectedType === 'array' && !Array.isArray(value)) {
107
111
  return `Parameter "${key}" must be an array`;
108
112
  }
109
- if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) {
113
+ if (
114
+ expectedType === 'object' &&
115
+ (typeof value !== 'object' || Array.isArray(value))
116
+ ) {
110
117
  return `Parameter "${key}" must be an object`;
111
118
  }
112
119