@friggframework/admin-scripts 2.0.0--canary.517.095e3f6.0 → 2.0.0--canary.517.06fe141.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.
- package/README.md +17 -12
- package/package.json +6 -6
- package/src/application/__tests__/admin-script-context.test.js +27 -118
- package/src/application/__tests__/script-runner.test.js +15 -15
- package/src/application/admin-script-context.js +13 -53
- package/src/application/script-runner.js +13 -8
- package/src/infrastructure/__tests__/admin-script-router.test.js +13 -11
- package/src/infrastructure/__tests__/bootstrap.test.js +35 -0
- package/src/infrastructure/__tests__/script-executor-handler.test.js +8 -3
- package/src/infrastructure/admin-script-router.js +25 -26
- package/src/infrastructure/bootstrap.js +46 -3
- package/src/infrastructure/script-executor-handler.js +15 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @friggframework/admin-scripts
|
|
2
2
|
|
|
3
|
-
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access
|
|
3
|
+
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access through the same Frigg commands your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
|
|
4
4
|
|
|
5
5
|
Typical use cases:
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Typical use cases:
|
|
|
8
8
|
- **Recurring maintenance** — refresh webhooks/subscriptions before they expire.
|
|
9
9
|
- **Built-in utilities** — OAuth token refresh, integration health checks.
|
|
10
10
|
|
|
11
|
-
> Admin scripts are a **high-privilege** surface. Every endpoint is protected by an admin API key (`x-frigg-admin-api-key`), scripts run in your private VPC subnets, and every execution is tracked in the `
|
|
11
|
+
> Admin scripts are a **high-privilege** surface. Every endpoint is protected by an admin API key (`x-frigg-admin-api-key`), scripts run in your private VPC subnets, and every execution is tracked in the `AdminScriptExecution` table. Never expose the admin API key to browsers or end users.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -87,12 +87,17 @@ class AttioHealingScript extends AdminScriptBase {
|
|
|
87
87
|
|
|
88
88
|
this.context.log('info', 'Starting Attio healing', { integrationId });
|
|
89
89
|
|
|
90
|
+
// Read persisted data through Frigg commands (never repositories).
|
|
91
|
+
// Commands return the data on success, or an { error, reason } object
|
|
92
|
+
// on failure — check `.error` yourself.
|
|
90
93
|
const integration =
|
|
91
|
-
await this.context.
|
|
94
|
+
await this.context.commands.integrations.findIntegrationById(
|
|
92
95
|
integrationId
|
|
93
96
|
);
|
|
94
|
-
if (
|
|
95
|
-
throw new Error(
|
|
97
|
+
if (integration.error) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Integration ${integrationId} not found: ${integration.reason}`
|
|
100
|
+
);
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
// Call the live integration when you need to hit an external API.
|
|
@@ -117,16 +122,16 @@ module.exports = { AttioHealingScript };
|
|
|
117
122
|
| Member | Description |
|
|
118
123
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
119
124
|
| `log(level, message, data?)` | Records a structured log entry (`level`: `debug`/`info`/`warn`/`error`). Logs are persisted to the execution's `results.logs`. |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
125
|
+
| `commands.users` | User commands (`findIndividualUserById`, `createUser`, `updateUser`, …). |
|
|
126
|
+
| `commands.credentials` | Credential commands (`findCredential`, `updateCredential`, …); secrets decrypted transparently. |
|
|
127
|
+
| `commands.entities` | Entity/module commands (`findEntityById`, `findEntitiesByUserId`, …). |
|
|
128
|
+
| `commands.integrations` | Integration reads (`findIntegrationById`, `listIntegrations({ type, status })`). |
|
|
124
129
|
| `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
|
|
125
130
|
| `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
|
|
126
131
|
| `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
|
|
127
|
-
| `getExecutionId()` | The current `
|
|
132
|
+
| `getExecutionId()` | The current `AdminScriptExecution` record id. |
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
Commands return **plain, decrypted data** on success (field-level encryption is handled transparently) or an `{ error, reason, code }` object on failure — scripts check `.error` themselves. Scripts have no direct repository access; all database interaction goes through the command layer.
|
|
130
135
|
|
|
131
136
|
---
|
|
132
137
|
|
|
@@ -242,7 +247,7 @@ async execute(params) {
|
|
|
242
247
|
- **Sync** (`mode: 'sync'`) — runs in the API Lambda, result returned in the response. Capped by the API timeout.
|
|
243
248
|
- **Async** (`mode: 'async'`, default) — queued to SQS and run by the worker Lambda (15-min budget). The SQS queue has a redrive policy (up to 3 receives) to a dead-letter queue, so a crashed invocation is retried at the infrastructure level. There is no application-level per-script retry — model idempotency accordingly.
|
|
244
249
|
|
|
245
|
-
Every execution is persisted as an `
|
|
250
|
+
Every execution is persisted as an `AdminScriptExecution` record with its `state` (`PENDING` → `RUNNING` → `COMPLETED`/`FAILED`), input, output, metrics, and logs.
|
|
246
251
|
|
|
247
252
|
---
|
|
248
253
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.517.
|
|
4
|
+
"version": "2.0.0--canary.517.06fe141.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.517.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.517.06fe141.0",
|
|
9
9
|
"@hapi/boom": "^10.0.1",
|
|
10
10
|
"express": "^4.18.2",
|
|
11
11
|
"serverless-http": "^3.2.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@friggframework/eslint-config": "2.0.0--canary.517.
|
|
15
|
-
"@friggframework/prettier-config": "2.0.0--canary.517.
|
|
16
|
-
"@friggframework/test": "2.0.0--canary.517.
|
|
14
|
+
"@friggframework/eslint-config": "2.0.0--canary.517.06fe141.0",
|
|
15
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.06fe141.0",
|
|
16
|
+
"@friggframework/test": "2.0.0--canary.517.06fe141.0",
|
|
17
17
|
"eslint": "^8.22.0",
|
|
18
18
|
"jest": "^29.7.0",
|
|
19
19
|
"prettier": "^2.7.1",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"maintenance",
|
|
45
45
|
"operations"
|
|
46
46
|
],
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "06fe14103a72028ce7bf622631580fb24a4abaaf"
|
|
48
48
|
}
|
|
@@ -1,80 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
1
3
|
const {
|
|
2
4
|
AdminScriptContext,
|
|
3
5
|
createAdminScriptContext,
|
|
4
6
|
} = require('../admin-script-context');
|
|
5
7
|
|
|
6
|
-
// Mock all repository factories
|
|
7
|
-
jest.mock(
|
|
8
|
-
'@friggframework/core/integrations/repositories/integration-repository-factory'
|
|
9
|
-
);
|
|
10
|
-
jest.mock('@friggframework/core/user/repositories/user-repository-factory');
|
|
11
|
-
jest.mock(
|
|
12
|
-
'@friggframework/core/modules/repositories/module-repository-factory'
|
|
13
|
-
);
|
|
14
|
-
jest.mock(
|
|
15
|
-
'@friggframework/core/credential/repositories/credential-repository-factory'
|
|
16
|
-
);
|
|
17
8
|
jest.mock('@friggframework/core/queues');
|
|
18
9
|
|
|
19
10
|
describe('AdminScriptContext', () => {
|
|
20
|
-
let mockIntegrationRepo;
|
|
21
|
-
let mockUserRepo;
|
|
22
|
-
let mockModuleRepo;
|
|
23
|
-
let mockCredentialRepo;
|
|
24
11
|
let mockQueuerUtil;
|
|
25
12
|
|
|
26
13
|
beforeEach(() => {
|
|
27
14
|
jest.clearAllMocks();
|
|
28
15
|
|
|
29
|
-
mockIntegrationRepo = {
|
|
30
|
-
findIntegrations: jest.fn(),
|
|
31
|
-
findIntegrationById: jest.fn(),
|
|
32
|
-
findIntegrationsByUserId: jest.fn(),
|
|
33
|
-
updateIntegrationConfig: jest.fn(),
|
|
34
|
-
updateIntegrationStatus: jest.fn(),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
mockUserRepo = {
|
|
38
|
-
findIndividualUserById: jest.fn(),
|
|
39
|
-
findIndividualUserByAppUserId: jest.fn(),
|
|
40
|
-
findIndividualUserByUsername: jest.fn(),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
mockModuleRepo = {
|
|
44
|
-
findEntity: jest.fn(),
|
|
45
|
-
findEntityById: jest.fn(),
|
|
46
|
-
findEntitiesByUserId: jest.fn(),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
mockCredentialRepo = {
|
|
50
|
-
findCredential: jest.fn(),
|
|
51
|
-
updateCredential: jest.fn(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
16
|
mockQueuerUtil = {
|
|
55
17
|
send: jest.fn().mockResolvedValue(undefined),
|
|
56
18
|
batchSend: jest.fn().mockResolvedValue(undefined),
|
|
57
19
|
};
|
|
58
20
|
|
|
59
|
-
const {
|
|
60
|
-
createIntegrationRepository,
|
|
61
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
62
|
-
const {
|
|
63
|
-
createUserRepository,
|
|
64
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
65
|
-
const {
|
|
66
|
-
createModuleRepository,
|
|
67
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
68
|
-
const {
|
|
69
|
-
createCredentialRepository,
|
|
70
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
71
21
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
72
|
-
|
|
73
|
-
createIntegrationRepository.mockReturnValue(mockIntegrationRepo);
|
|
74
|
-
createUserRepository.mockReturnValue(mockUserRepo);
|
|
75
|
-
createModuleRepository.mockReturnValue(mockModuleRepo);
|
|
76
|
-
createCredentialRepository.mockReturnValue(mockCredentialRepo);
|
|
77
|
-
|
|
78
22
|
QueuerUtil.send = mockQueuerUtil.send;
|
|
79
23
|
QueuerUtil.batchSend = mockQueuerUtil.batchSend;
|
|
80
24
|
});
|
|
@@ -86,6 +30,19 @@ describe('AdminScriptContext', () => {
|
|
|
86
30
|
expect(ctx.executionId).toBe('exec_123');
|
|
87
31
|
expect(ctx.logs).toEqual([]);
|
|
88
32
|
expect(ctx.integrationFactory).toBeNull();
|
|
33
|
+
expect(ctx.commands).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes the injected command bundle as context.commands', () => {
|
|
37
|
+
const commands = {
|
|
38
|
+
users: {},
|
|
39
|
+
credentials: {},
|
|
40
|
+
entities: {},
|
|
41
|
+
integrations: {},
|
|
42
|
+
};
|
|
43
|
+
const ctx = new AdminScriptContext({ commands });
|
|
44
|
+
|
|
45
|
+
expect(ctx.commands).toBe(commands);
|
|
89
46
|
});
|
|
90
47
|
|
|
91
48
|
it('creates with integrationFactory', () => {
|
|
@@ -106,71 +63,23 @@ describe('AdminScriptContext', () => {
|
|
|
106
63
|
});
|
|
107
64
|
});
|
|
108
65
|
|
|
109
|
-
describe('
|
|
110
|
-
it('
|
|
66
|
+
describe('command surface (no direct repository access)', () => {
|
|
67
|
+
it('does not expose repository getters', () => {
|
|
111
68
|
const ctx = new AdminScriptContext();
|
|
112
|
-
const {
|
|
113
|
-
createIntegrationRepository,
|
|
114
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
115
|
-
|
|
116
|
-
expect(createIntegrationRepository).not.toHaveBeenCalled();
|
|
117
69
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
70
|
+
expect(ctx.integrationRepository).toBeUndefined();
|
|
71
|
+
expect(ctx.userRepository).toBeUndefined();
|
|
72
|
+
expect(ctx.moduleRepository).toBeUndefined();
|
|
73
|
+
expect(ctx.credentialRepository).toBeUndefined();
|
|
122
74
|
});
|
|
123
75
|
|
|
124
|
-
it('
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(repo1).toBe(repo2);
|
|
131
|
-
expect(repo1).toBe(mockIntegrationRepo);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('creates userRepository on first access', () => {
|
|
135
|
-
const ctx = new AdminScriptContext();
|
|
136
|
-
const {
|
|
137
|
-
createUserRepository,
|
|
138
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
139
|
-
|
|
140
|
-
expect(createUserRepository).not.toHaveBeenCalled();
|
|
141
|
-
|
|
142
|
-
const repo = ctx.userRepository;
|
|
143
|
-
|
|
144
|
-
expect(createUserRepository).toHaveBeenCalledTimes(1);
|
|
145
|
-
expect(repo).toBe(mockUserRepo);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('creates moduleRepository on first access', () => {
|
|
149
|
-
const ctx = new AdminScriptContext();
|
|
150
|
-
const {
|
|
151
|
-
createModuleRepository,
|
|
152
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
153
|
-
|
|
154
|
-
expect(createModuleRepository).not.toHaveBeenCalled();
|
|
155
|
-
|
|
156
|
-
const repo = ctx.moduleRepository;
|
|
157
|
-
|
|
158
|
-
expect(createModuleRepository).toHaveBeenCalledTimes(1);
|
|
159
|
-
expect(repo).toBe(mockModuleRepo);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('creates credentialRepository on first access', () => {
|
|
163
|
-
const ctx = new AdminScriptContext();
|
|
164
|
-
const {
|
|
165
|
-
createCredentialRepository,
|
|
166
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
167
|
-
|
|
168
|
-
expect(createCredentialRepository).not.toHaveBeenCalled();
|
|
169
|
-
|
|
170
|
-
const repo = ctx.credentialRepository;
|
|
76
|
+
it('the context module never imports a repository factory', () => {
|
|
77
|
+
const source = fs.readFileSync(
|
|
78
|
+
path.join(__dirname, '..', 'admin-script-context.js'),
|
|
79
|
+
'utf8'
|
|
80
|
+
);
|
|
171
81
|
|
|
172
|
-
expect(
|
|
173
|
-
expect(repo).toBe(mockCredentialRepo);
|
|
82
|
+
expect(source).not.toMatch(/repository-factory/);
|
|
174
83
|
});
|
|
175
84
|
});
|
|
176
85
|
|
|
@@ -38,9 +38,9 @@ describe('ScriptRunner', () => {
|
|
|
38
38
|
scriptFactory = new ScriptFactory([TestScript]);
|
|
39
39
|
|
|
40
40
|
mockCommands = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
createExecution: jest.fn(),
|
|
42
|
+
updateExecutionState: jest.fn(),
|
|
43
|
+
completeExecution: jest.fn(),
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
mockContext = {
|
|
@@ -52,11 +52,11 @@ describe('ScriptRunner', () => {
|
|
|
52
52
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
53
53
|
createAdminScriptContext.mockReturnValue(mockContext);
|
|
54
54
|
|
|
55
|
-
mockCommands.
|
|
55
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
56
56
|
id: 'exec-123',
|
|
57
57
|
});
|
|
58
|
-
mockCommands.
|
|
59
|
-
mockCommands.
|
|
58
|
+
mockCommands.updateExecutionState.mockResolvedValue({});
|
|
59
|
+
mockCommands.completeExecution.mockResolvedValue({ success: true });
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
afterEach(() => {
|
|
@@ -89,7 +89,7 @@ describe('ScriptRunner', () => {
|
|
|
89
89
|
expect(result.executionId).toBe('exec-123');
|
|
90
90
|
expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0);
|
|
91
91
|
|
|
92
|
-
expect(mockCommands.
|
|
92
|
+
expect(mockCommands.createExecution).toHaveBeenCalledWith({
|
|
93
93
|
scriptName: 'test-script',
|
|
94
94
|
scriptVersion: '1.0.0',
|
|
95
95
|
trigger: 'MANUAL',
|
|
@@ -98,12 +98,12 @@ describe('ScriptRunner', () => {
|
|
|
98
98
|
audit: { apiKeyName: 'test-key' },
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
expect(mockCommands.
|
|
101
|
+
expect(mockCommands.updateExecutionState).toHaveBeenCalledWith(
|
|
102
102
|
'exec-123',
|
|
103
103
|
'RUNNING'
|
|
104
104
|
);
|
|
105
105
|
|
|
106
|
-
expect(mockCommands.
|
|
106
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
107
107
|
'exec-123',
|
|
108
108
|
expect.objectContaining({
|
|
109
109
|
state: 'COMPLETED',
|
|
@@ -170,7 +170,7 @@ describe('ScriptRunner', () => {
|
|
|
170
170
|
expect(result.scriptName).toBe('failing-script');
|
|
171
171
|
expect(result.error.message).toBe('Script failed');
|
|
172
172
|
|
|
173
|
-
expect(mockCommands.
|
|
173
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
174
174
|
'exec-123',
|
|
175
175
|
expect.objectContaining({
|
|
176
176
|
state: 'FAILED',
|
|
@@ -227,8 +227,8 @@ describe('ScriptRunner', () => {
|
|
|
227
227
|
);
|
|
228
228
|
|
|
229
229
|
expect(result.executionId).toBe('existing-exec-456');
|
|
230
|
-
expect(mockCommands.
|
|
231
|
-
expect(mockCommands.
|
|
230
|
+
expect(mockCommands.createExecution).not.toHaveBeenCalled();
|
|
231
|
+
expect(mockCommands.updateExecutionState).toHaveBeenCalledWith(
|
|
232
232
|
'existing-exec-456',
|
|
233
233
|
'RUNNING'
|
|
234
234
|
);
|
|
@@ -237,7 +237,7 @@ describe('ScriptRunner', () => {
|
|
|
237
237
|
it('reports COMPLETED even when persisting completion fails', async () => {
|
|
238
238
|
// Commands return an error object (never throw). A successful script
|
|
239
239
|
// must not be misreported as FAILED if the completion write fails.
|
|
240
|
-
mockCommands.
|
|
240
|
+
mockCommands.completeExecution.mockResolvedValue({
|
|
241
241
|
error: 500,
|
|
242
242
|
reason: 'DB write failed',
|
|
243
243
|
});
|
|
@@ -257,7 +257,7 @@ describe('ScriptRunner', () => {
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
it('throws when the execution record cannot be created', async () => {
|
|
260
|
-
mockCommands.
|
|
260
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
261
261
|
error: 500,
|
|
262
262
|
reason: 'DB down',
|
|
263
263
|
});
|
|
@@ -282,7 +282,7 @@ describe('ScriptRunner', () => {
|
|
|
282
282
|
|
|
283
283
|
await runner.execute('test-script', {}, { trigger: 'MANUAL' });
|
|
284
284
|
|
|
285
|
-
expect(mockCommands.
|
|
285
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
286
286
|
'exec-123',
|
|
287
287
|
expect.objectContaining({
|
|
288
288
|
logs: [{ level: 'info', message: 'hi' }],
|
|
@@ -3,23 +3,27 @@ const { QueuerUtil } = require('@friggframework/core/queues');
|
|
|
3
3
|
/**
|
|
4
4
|
* AdminScriptContext - Execution environment for admin scripts
|
|
5
5
|
*
|
|
6
|
-
* Provides a controlled surface area for scripts to interact with
|
|
7
|
-
*
|
|
6
|
+
* Provides a controlled surface area for scripts to interact with the Frigg
|
|
7
|
+
* platform. Scripts touch the database only through injected Frigg commands
|
|
8
|
+
* (`context.commands`) — never through repositories directly. Capabilities:
|
|
8
9
|
*
|
|
10
|
+
* - **Frigg commands**: `commands.users`, `commands.credentials`,
|
|
11
|
+
* `commands.entities`, and `commands.integrations` expose the framework's
|
|
12
|
+
* command layer. Each command returns data on success or an `{ error }`
|
|
13
|
+
* object on failure — scripts check `.error` themselves.
|
|
9
14
|
* - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to
|
|
10
15
|
* skip user-ownership checks when loading integration instances
|
|
11
16
|
* - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts
|
|
12
17
|
* enqueue follow-up work with parent execution tracking
|
|
13
18
|
* - **Execution-scoped logging**: `log()` collects structured entries tied
|
|
14
19
|
* to the current execution for post-run inspection
|
|
15
|
-
* - **Lazy-loaded repositories**: Repos are exposed directly as getters
|
|
16
|
-
* so scripts can query any data they need without wrapper indirection
|
|
17
20
|
*/
|
|
18
21
|
class AdminScriptContext {
|
|
19
22
|
/**
|
|
20
23
|
* @param {Object} [params={}] - Context configuration
|
|
21
|
-
* @param {string|number|null} [params.executionId] - ID of the
|
|
24
|
+
* @param {string|number|null} [params.executionId] - ID of the AdminScriptExecution record this context is scoped to (used for log persistence and script chaining)
|
|
22
25
|
* @param {Object|null} [params.integrationFactory] - Factory used to hydrate integration instances; required for scripts that call instantiate()
|
|
26
|
+
* @param {Object|null} [params.commands] - Frigg command bundle ({ users, credentials, entities, integrations }) injected by the composition root and exposed to scripts as context.commands
|
|
23
27
|
*/
|
|
24
28
|
constructor(params = {}) {
|
|
25
29
|
this.executionId = params.executionId || null;
|
|
@@ -27,54 +31,10 @@ class AdminScriptContext {
|
|
|
27
31
|
|
|
28
32
|
this.integrationFactory = params.integrationFactory || null;
|
|
29
33
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
this.
|
|
34
|
-
this._moduleRepository = null;
|
|
35
|
-
this._credentialRepository = null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ==================== LAZY-LOADED REPOSITORIES ====================
|
|
39
|
-
|
|
40
|
-
get integrationRepository() {
|
|
41
|
-
if (!this._integrationRepository) {
|
|
42
|
-
const {
|
|
43
|
-
createIntegrationRepository,
|
|
44
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
45
|
-
this._integrationRepository = createIntegrationRepository();
|
|
46
|
-
}
|
|
47
|
-
return this._integrationRepository;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get userRepository() {
|
|
51
|
-
if (!this._userRepository) {
|
|
52
|
-
const {
|
|
53
|
-
createUserRepository,
|
|
54
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
55
|
-
this._userRepository = createUserRepository();
|
|
56
|
-
}
|
|
57
|
-
return this._userRepository;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get moduleRepository() {
|
|
61
|
-
if (!this._moduleRepository) {
|
|
62
|
-
const {
|
|
63
|
-
createModuleRepository,
|
|
64
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
65
|
-
this._moduleRepository = createModuleRepository();
|
|
66
|
-
}
|
|
67
|
-
return this._moduleRepository;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
get credentialRepository() {
|
|
71
|
-
if (!this._credentialRepository) {
|
|
72
|
-
const {
|
|
73
|
-
createCredentialRepository,
|
|
74
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
75
|
-
this._credentialRepository = createCredentialRepository();
|
|
76
|
-
}
|
|
77
|
-
return this._credentialRepository;
|
|
34
|
+
// Scripts interact with the database only through these Frigg commands.
|
|
35
|
+
// Injected by bootstrap.js — the context never reaches for repositories
|
|
36
|
+
// or command factories itself.
|
|
37
|
+
this.commands = params.commands || null;
|
|
78
38
|
}
|
|
79
39
|
|
|
80
40
|
// ==================== INTEGRATION INSTANTIATION ====================
|
|
@@ -17,10 +17,12 @@ class ScriptRunner {
|
|
|
17
17
|
* @param {Object} params
|
|
18
18
|
* @param {ScriptFactory} params.scriptFactory - Required. The registry used
|
|
19
19
|
* to resolve and instantiate scripts by name (built by bootstrap.js).
|
|
20
|
-
* @param {Object} [params.commands] - Admin
|
|
20
|
+
* @param {Object} [params.commands] - Admin script execution command layer; defaults
|
|
21
21
|
* to a fresh createAdminScriptCommands().
|
|
22
22
|
* @param {Object} [params.integrationFactory] - Hydrates integration
|
|
23
23
|
* instances for scripts that need them.
|
|
24
|
+
* @param {Object} [params.scriptCommands] - Frigg command bundle exposed to
|
|
25
|
+
* scripts as context.commands (built by bootstrap.js).
|
|
24
26
|
*/
|
|
25
27
|
constructor(params = {}) {
|
|
26
28
|
if (!params.scriptFactory) {
|
|
@@ -29,6 +31,7 @@ class ScriptRunner {
|
|
|
29
31
|
this.scriptFactory = params.scriptFactory;
|
|
30
32
|
this.commands = params.commands || createAdminScriptCommands();
|
|
31
33
|
this.integrationFactory = params.integrationFactory || null;
|
|
34
|
+
this.scriptCommands = params.scriptCommands || null;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -39,8 +42,8 @@ class ScriptRunner {
|
|
|
39
42
|
* @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE'
|
|
40
43
|
* @param {string} options.mode - 'sync' | 'async'
|
|
41
44
|
* @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress }
|
|
42
|
-
* @param {string} options.executionId - Reuse existing
|
|
43
|
-
* This is the database ID from the
|
|
45
|
+
* @param {string} options.executionId - Reuse existing AdminScriptExecution record ID (NOT the Lambda execution ID).
|
|
46
|
+
* This is the database ID from the AdminScriptExecution collection/table that tracks script executions.
|
|
44
47
|
* Pass this when resuming a queued execution to continue using the same execution record.
|
|
45
48
|
*/
|
|
46
49
|
async execute(scriptName, params = {}, options = {}) {
|
|
@@ -75,7 +78,7 @@ class ScriptRunner {
|
|
|
75
78
|
|
|
76
79
|
// Create execution record if not provided
|
|
77
80
|
if (!executionId) {
|
|
78
|
-
const execution = await this.commands.
|
|
81
|
+
const execution = await this.commands.createExecution({
|
|
79
82
|
scriptName,
|
|
80
83
|
scriptVersion: definition.version,
|
|
81
84
|
trigger,
|
|
@@ -88,7 +91,8 @@ class ScriptRunner {
|
|
|
88
91
|
// than tracking an `undefined` execution id.
|
|
89
92
|
if (execution.error) {
|
|
90
93
|
throw new Error(
|
|
91
|
-
execution.reason ||
|
|
94
|
+
execution.reason ||
|
|
95
|
+
'Failed to create admin script execution record'
|
|
92
96
|
);
|
|
93
97
|
}
|
|
94
98
|
executionId = execution.id;
|
|
@@ -100,11 +104,12 @@ class ScriptRunner {
|
|
|
100
104
|
const context = createAdminScriptContext({
|
|
101
105
|
executionId,
|
|
102
106
|
integrationFactory: this.integrationFactory,
|
|
107
|
+
commands: this.scriptCommands,
|
|
103
108
|
});
|
|
104
109
|
|
|
105
110
|
let output;
|
|
106
111
|
try {
|
|
107
|
-
await this.commands.
|
|
112
|
+
await this.commands.updateExecutionState(executionId, 'RUNNING');
|
|
108
113
|
|
|
109
114
|
// Create script instance with context injected via constructor
|
|
110
115
|
const script = this.scriptFactory.createInstance(scriptName, {
|
|
@@ -118,7 +123,7 @@ class ScriptRunner {
|
|
|
118
123
|
} catch (error) {
|
|
119
124
|
const durationMs = new Date() - startTime;
|
|
120
125
|
|
|
121
|
-
const completion = await this.commands.
|
|
126
|
+
const completion = await this.commands.completeExecution(
|
|
122
127
|
executionId,
|
|
123
128
|
{
|
|
124
129
|
state: 'FAILED',
|
|
@@ -165,7 +170,7 @@ class ScriptRunner {
|
|
|
165
170
|
metrics: { durationMs },
|
|
166
171
|
};
|
|
167
172
|
|
|
168
|
-
const completion = await this.commands.
|
|
173
|
+
const completion = await this.commands.completeExecution(
|
|
169
174
|
executionId,
|
|
170
175
|
{
|
|
171
176
|
state: 'COMPLETED',
|
|
@@ -58,9 +58,9 @@ describe('Admin Script Router', () => {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
mockCommands = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
createExecution: jest.fn(),
|
|
62
|
+
findExecutionById: jest.fn(),
|
|
63
|
+
findExecutionsByName: jest.fn(),
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
mockSchedulerAdapter = {
|
|
@@ -72,6 +72,7 @@ describe('Admin Script Router', () => {
|
|
|
72
72
|
bootstrapAdminScripts.mockReturnValue({
|
|
73
73
|
scriptFactory: mockFactory,
|
|
74
74
|
integrationFactory: {},
|
|
75
|
+
scriptCommands: {},
|
|
75
76
|
});
|
|
76
77
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
77
78
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
@@ -177,13 +178,14 @@ describe('Admin Script Router', () => {
|
|
|
177
178
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
178
179
|
scriptFactory: mockFactory,
|
|
179
180
|
integrationFactory: {},
|
|
181
|
+
scriptCommands: {},
|
|
180
182
|
});
|
|
181
183
|
});
|
|
182
184
|
|
|
183
185
|
it('should queue script for async execution', async () => {
|
|
184
186
|
process.env.ADMIN_SCRIPT_QUEUE_URL =
|
|
185
187
|
'https://sqs.us-east-1.amazonaws.com/123/test-queue';
|
|
186
|
-
mockCommands.
|
|
188
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
187
189
|
id: 'exec-456',
|
|
188
190
|
});
|
|
189
191
|
|
|
@@ -210,7 +212,7 @@ describe('Admin Script Router', () => {
|
|
|
210
212
|
it('should default to async mode', async () => {
|
|
211
213
|
process.env.ADMIN_SCRIPT_QUEUE_URL =
|
|
212
214
|
'https://sqs.us-east-1.amazonaws.com/123/test-queue';
|
|
213
|
-
mockCommands.
|
|
215
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
214
216
|
id: 'exec-789',
|
|
215
217
|
});
|
|
216
218
|
|
|
@@ -255,7 +257,7 @@ describe('Admin Script Router', () => {
|
|
|
255
257
|
|
|
256
258
|
describe('GET /admin/scripts/:scriptName/executions/:executionId', () => {
|
|
257
259
|
it('should return execution details', async () => {
|
|
258
|
-
mockCommands.
|
|
260
|
+
mockCommands.findExecutionById.mockResolvedValue({
|
|
259
261
|
id: 'exec-123',
|
|
260
262
|
scriptName: 'test-script',
|
|
261
263
|
status: 'COMPLETED',
|
|
@@ -271,7 +273,7 @@ describe('Admin Script Router', () => {
|
|
|
271
273
|
});
|
|
272
274
|
|
|
273
275
|
it('should return 404 for non-existent execution', async () => {
|
|
274
|
-
mockCommands.
|
|
276
|
+
mockCommands.findExecutionById.mockResolvedValue({
|
|
275
277
|
error: 404,
|
|
276
278
|
reason: 'Execution not found',
|
|
277
279
|
code: 'EXECUTION_NOT_FOUND',
|
|
@@ -288,7 +290,7 @@ describe('Admin Script Router', () => {
|
|
|
288
290
|
|
|
289
291
|
describe('GET /admin/scripts/:scriptName/executions', () => {
|
|
290
292
|
it('should list executions for specific script', async () => {
|
|
291
|
-
mockCommands.
|
|
293
|
+
mockCommands.findExecutionsByName.mockResolvedValue([
|
|
292
294
|
{ id: 'exec-1', name: 'test-script', state: 'COMPLETED' },
|
|
293
295
|
{ id: 'exec-2', name: 'test-script', state: 'RUNNING' },
|
|
294
296
|
]);
|
|
@@ -299,7 +301,7 @@ describe('Admin Script Router', () => {
|
|
|
299
301
|
|
|
300
302
|
expect(response.status).toBe(200);
|
|
301
303
|
expect(response.body.executions).toHaveLength(2);
|
|
302
|
-
expect(mockCommands.
|
|
304
|
+
expect(mockCommands.findExecutionsByName).toHaveBeenCalledWith(
|
|
303
305
|
'test-script',
|
|
304
306
|
{
|
|
305
307
|
limit: 50,
|
|
@@ -308,13 +310,13 @@ describe('Admin Script Router', () => {
|
|
|
308
310
|
});
|
|
309
311
|
|
|
310
312
|
it('should accept query parameters (status maps to state, limit is bounded)', async () => {
|
|
311
|
-
mockCommands.
|
|
313
|
+
mockCommands.findExecutionsByName.mockResolvedValue([]);
|
|
312
314
|
|
|
313
315
|
await request(app).get(
|
|
314
316
|
'/admin/scripts/test-script/executions?status=COMPLETED&limit=10'
|
|
315
317
|
);
|
|
316
318
|
|
|
317
|
-
expect(mockCommands.
|
|
319
|
+
expect(mockCommands.findExecutionsByName).toHaveBeenCalledWith(
|
|
318
320
|
'test-script',
|
|
319
321
|
{
|
|
320
322
|
limit: 10,
|
|
@@ -3,6 +3,27 @@ const { AdminScriptBase } = require('../../application/admin-script-base');
|
|
|
3
3
|
|
|
4
4
|
jest.mock('@friggframework/core/handlers/app-definition-loader');
|
|
5
5
|
|
|
6
|
+
// The command bundle is built from these factories; mock them so bootstrap
|
|
7
|
+
// doesn't need a configured database to construct the command groups.
|
|
8
|
+
jest.mock('@friggframework/core/application/commands/user-commands', () => ({
|
|
9
|
+
createUserCommands: jest.fn(() => ({ __kind: 'users' })),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock(
|
|
12
|
+
'@friggframework/core/application/commands/credential-commands',
|
|
13
|
+
() => ({
|
|
14
|
+
createCredentialCommands: jest.fn(() => ({ __kind: 'credentials' })),
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
jest.mock('@friggframework/core/application/commands/entity-commands', () => ({
|
|
18
|
+
createEntityCommands: jest.fn(() => ({ __kind: 'entities' })),
|
|
19
|
+
}));
|
|
20
|
+
jest.mock(
|
|
21
|
+
'@friggframework/core/application/commands/integration-commands',
|
|
22
|
+
() => ({
|
|
23
|
+
createIntegrationCommands: jest.fn(() => ({ __kind: 'integrations' })),
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
|
|
6
27
|
const {
|
|
7
28
|
loadAppDefinition,
|
|
8
29
|
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
@@ -64,9 +85,23 @@ describe('bootstrapAdminScripts', () => {
|
|
|
64
85
|
|
|
65
86
|
expect(second.scriptFactory).toBe(first.scriptFactory);
|
|
66
87
|
expect(second.integrationFactory).toBe(first.integrationFactory);
|
|
88
|
+
expect(second.scriptCommands).toBe(first.scriptCommands);
|
|
67
89
|
expect(loadAppDefinition).toHaveBeenCalledTimes(1);
|
|
68
90
|
});
|
|
69
91
|
|
|
92
|
+
it('builds the injectable command bundle (users, credentials, entities, integrations)', () => {
|
|
93
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [] });
|
|
94
|
+
|
|
95
|
+
const { scriptCommands } = bootstrapAdminScripts();
|
|
96
|
+
|
|
97
|
+
expect(scriptCommands).toEqual({
|
|
98
|
+
users: { __kind: 'users' },
|
|
99
|
+
credentials: { __kind: 'credentials' },
|
|
100
|
+
entities: { __kind: 'entities' },
|
|
101
|
+
integrations: { __kind: 'integrations' },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
70
105
|
it('tolerates an app definition with no adminScripts', () => {
|
|
71
106
|
loadAppDefinition.mockReturnValue({});
|
|
72
107
|
|
|
@@ -12,20 +12,23 @@ const { handler } = require('../script-executor-handler');
|
|
|
12
12
|
describe('Admin Script Executor Handler', () => {
|
|
13
13
|
let mockScriptFactory;
|
|
14
14
|
let mockIntegrationFactory;
|
|
15
|
+
let mockScriptCommands;
|
|
15
16
|
let mockRunner;
|
|
16
17
|
let mockCommands;
|
|
17
18
|
|
|
18
19
|
beforeEach(() => {
|
|
19
20
|
mockScriptFactory = { id: 'script-factory' };
|
|
20
21
|
mockIntegrationFactory = { id: 'integration-factory' };
|
|
22
|
+
mockScriptCommands = { id: 'script-commands' };
|
|
21
23
|
mockRunner = { execute: jest.fn() };
|
|
22
24
|
mockCommands = {
|
|
23
|
-
|
|
25
|
+
completeExecution: jest.fn().mockResolvedValue({}),
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
bootstrapAdminScripts.mockReturnValue({
|
|
27
29
|
scriptFactory: mockScriptFactory,
|
|
28
30
|
integrationFactory: mockIntegrationFactory,
|
|
31
|
+
scriptCommands: mockScriptCommands,
|
|
29
32
|
});
|
|
30
33
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
31
34
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
@@ -56,6 +59,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
56
59
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
57
60
|
scriptFactory: mockScriptFactory,
|
|
58
61
|
integrationFactory: mockIntegrationFactory,
|
|
62
|
+
scriptCommands: mockScriptCommands,
|
|
59
63
|
});
|
|
60
64
|
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
61
65
|
'my-script',
|
|
@@ -85,7 +89,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
85
89
|
expect(response.statusCode).toBe(200);
|
|
86
90
|
const body = JSON.parse(response.body);
|
|
87
91
|
expect(body.results[0].status).toBe('FAILED');
|
|
88
|
-
expect(mockCommands.
|
|
92
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
89
93
|
'exec-2',
|
|
90
94
|
expect.objectContaining({ state: 'FAILED' })
|
|
91
95
|
);
|
|
@@ -114,6 +118,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
114
118
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
115
119
|
scriptFactory: mockScriptFactory,
|
|
116
120
|
integrationFactory: mockIntegrationFactory,
|
|
121
|
+
scriptCommands: mockScriptCommands,
|
|
117
122
|
});
|
|
118
123
|
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
119
124
|
'my-script',
|
|
@@ -154,7 +159,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
154
159
|
expect(body.processed).toBe(2);
|
|
155
160
|
expect(body.results[0].status).toBe('FAILED');
|
|
156
161
|
expect(body.results[1].status).toBe('COMPLETED');
|
|
157
|
-
expect(mockCommands.
|
|
162
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
158
163
|
'exec-bad',
|
|
159
164
|
expect.objectContaining({ state: 'FAILED' })
|
|
160
165
|
);
|
|
@@ -23,19 +23,12 @@ const {
|
|
|
23
23
|
|
|
24
24
|
const router = express.Router();
|
|
25
25
|
|
|
26
|
-
// Apply auth middleware to all admin routes
|
|
26
|
+
// Apply auth middleware to all admin routes. Each handler then calls
|
|
27
|
+
// bootstrapAdminScripts() (memoized, so it runs once per process) to obtain the
|
|
28
|
+
// scriptFactory / integrationFactory / scriptCommands it needs — no global, and
|
|
29
|
+
// endpoints that don't touch scripts skip the work entirely.
|
|
27
30
|
router.use(validateAdminApiKey);
|
|
28
31
|
|
|
29
|
-
// Build (once, memoized) and inject the per-process dependencies onto the
|
|
30
|
-
// request, so route handlers consume them explicitly instead of reaching for a
|
|
31
|
-
// global. Registers the host app's admin scripts on the first request.
|
|
32
|
-
router.use((req, _res, next) => {
|
|
33
|
-
const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
|
|
34
|
-
req.scriptFactory = scriptFactory;
|
|
35
|
-
req.integrationFactory = integrationFactory;
|
|
36
|
-
next();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
32
|
/**
|
|
40
33
|
* Translate a thrown error into an HTTP response. Boom errors (thrown by the
|
|
41
34
|
* schedule use cases) carry their own status code; anything else is an
|
|
@@ -116,9 +109,9 @@ function createScheduleUseCases(scriptFactory) {
|
|
|
116
109
|
* GET /admin/scripts
|
|
117
110
|
* List all registered scripts
|
|
118
111
|
*/
|
|
119
|
-
router.get('/scripts', async (
|
|
112
|
+
router.get('/scripts', async (_req, res) => {
|
|
120
113
|
try {
|
|
121
|
-
const factory =
|
|
114
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
122
115
|
const scripts = factory.getAll();
|
|
123
116
|
|
|
124
117
|
res.json({
|
|
@@ -144,7 +137,7 @@ router.get('/scripts', async (req, res) => {
|
|
|
144
137
|
router.get('/scripts/:scriptName', async (req, res) => {
|
|
145
138
|
try {
|
|
146
139
|
const { scriptName } = req.params;
|
|
147
|
-
const factory =
|
|
140
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
148
141
|
|
|
149
142
|
if (!factory.has(scriptName)) {
|
|
150
143
|
return res.status(404).json({
|
|
@@ -179,7 +172,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
|
|
|
179
172
|
try {
|
|
180
173
|
const { scriptName } = req.params;
|
|
181
174
|
const { params = {} } = req.body;
|
|
182
|
-
const factory =
|
|
175
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
183
176
|
|
|
184
177
|
if (!factory.has(scriptName)) {
|
|
185
178
|
return res.status(404).json({
|
|
@@ -204,7 +197,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
204
197
|
try {
|
|
205
198
|
const { scriptName } = req.params;
|
|
206
199
|
const { params = {}, mode = 'async' } = req.body;
|
|
207
|
-
const
|
|
200
|
+
const {
|
|
201
|
+
scriptFactory: factory,
|
|
202
|
+
integrationFactory,
|
|
203
|
+
scriptCommands,
|
|
204
|
+
} = bootstrapAdminScripts();
|
|
208
205
|
|
|
209
206
|
if (!factory.has(scriptName)) {
|
|
210
207
|
return res.status(404).json({
|
|
@@ -242,8 +239,9 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
242
239
|
}
|
|
243
240
|
|
|
244
241
|
const runner = createScriptRunner({
|
|
245
|
-
scriptFactory:
|
|
246
|
-
integrationFactory
|
|
242
|
+
scriptFactory: factory,
|
|
243
|
+
integrationFactory,
|
|
244
|
+
scriptCommands,
|
|
247
245
|
});
|
|
248
246
|
const result = await runner.execute(scriptName, params, {
|
|
249
247
|
trigger: 'MANUAL',
|
|
@@ -263,7 +261,7 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
263
261
|
}
|
|
264
262
|
|
|
265
263
|
const commands = createAdminScriptCommands();
|
|
266
|
-
const execution = await commands.
|
|
264
|
+
const execution = await commands.createExecution({
|
|
267
265
|
scriptName,
|
|
268
266
|
scriptVersion: definition.version,
|
|
269
267
|
trigger: 'MANUAL',
|
|
@@ -311,7 +309,7 @@ router.get('/scripts/:scriptName/executions/:executionId', async (req, res) => {
|
|
|
311
309
|
try {
|
|
312
310
|
const { executionId } = req.params;
|
|
313
311
|
const commands = createAdminScriptCommands();
|
|
314
|
-
const execution = await commands.
|
|
312
|
+
const execution = await commands.findExecutionById(executionId);
|
|
315
313
|
|
|
316
314
|
if (execution.error) {
|
|
317
315
|
return res.status(execution.error).json({
|
|
@@ -342,7 +340,7 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
342
340
|
? 50
|
|
343
341
|
: Math.min(Math.max(parsedLimit, 1), 200);
|
|
344
342
|
|
|
345
|
-
const executions = await commands.
|
|
343
|
+
const executions = await commands.findExecutionsByName(scriptName, {
|
|
346
344
|
limit: safeLimit,
|
|
347
345
|
...(status && { state: status }),
|
|
348
346
|
});
|
|
@@ -361,9 +359,8 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
361
359
|
router.get('/scripts/:scriptName/schedule', async (req, res) => {
|
|
362
360
|
try {
|
|
363
361
|
const { scriptName } = req.params;
|
|
364
|
-
const {
|
|
365
|
-
|
|
366
|
-
);
|
|
362
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
363
|
+
const { getEffectiveSchedule } = createScheduleUseCases(scriptFactory);
|
|
367
364
|
|
|
368
365
|
const result = await getEffectiveSchedule.execute(scriptName);
|
|
369
366
|
|
|
@@ -385,7 +382,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
385
382
|
try {
|
|
386
383
|
const { scriptName } = req.params;
|
|
387
384
|
const { enabled, cronExpression, timezone } = req.body;
|
|
388
|
-
const {
|
|
385
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
386
|
+
const { upsertSchedule } = createScheduleUseCases(scriptFactory);
|
|
389
387
|
|
|
390
388
|
const result = await upsertSchedule.execute(scriptName, {
|
|
391
389
|
enabled,
|
|
@@ -415,7 +413,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
415
413
|
router.delete('/scripts/:scriptName/schedule', async (req, res) => {
|
|
416
414
|
try {
|
|
417
415
|
const { scriptName } = req.params;
|
|
418
|
-
const {
|
|
416
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
417
|
+
const { deleteSchedule } = createScheduleUseCases(scriptFactory);
|
|
419
418
|
|
|
420
419
|
const result = await deleteSchedule.execute(scriptName);
|
|
421
420
|
|
|
@@ -15,6 +15,7 @@ const { ScriptFactory } = require('../application/script-factory');
|
|
|
15
15
|
*/
|
|
16
16
|
let bootstrapped = false;
|
|
17
17
|
let scriptFactory = null;
|
|
18
|
+
let scriptCommands = null;
|
|
18
19
|
let integrationFactory = null;
|
|
19
20
|
|
|
20
21
|
function registerScripts(factory, scriptClasses) {
|
|
@@ -45,11 +46,41 @@ function createIntegrationFactory() {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
+
* Build the command bundle injected into every AdminScriptContext. Scripts
|
|
50
|
+
* interact with the database only through these Frigg commands — never through
|
|
51
|
+
* repositories directly. The require is lazy so the package keeps no load-time
|
|
52
|
+
* coupling to core's command/repository modules.
|
|
53
|
+
*/
|
|
54
|
+
function buildScriptCommands() {
|
|
55
|
+
const {
|
|
56
|
+
createUserCommands,
|
|
57
|
+
} = require('@friggframework/core/application/commands/user-commands');
|
|
58
|
+
const {
|
|
59
|
+
createCredentialCommands,
|
|
60
|
+
} = require('@friggframework/core/application/commands/credential-commands');
|
|
61
|
+
const {
|
|
62
|
+
createEntityCommands,
|
|
63
|
+
} = require('@friggframework/core/application/commands/entity-commands');
|
|
64
|
+
const {
|
|
65
|
+
createIntegrationCommands,
|
|
66
|
+
} = require('@friggframework/core/application/commands/integration-commands');
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
users: createUserCommands(),
|
|
70
|
+
credentials: createCredentialCommands(),
|
|
71
|
+
entities: createEntityCommands(),
|
|
72
|
+
// Class-agnostic reads (findIntegrationById, listIntegrations). Scripts
|
|
73
|
+
// that need class-scoped integration ops build their own commands.
|
|
74
|
+
integrations: createIntegrationCommands(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
|
|
49
80
|
*/
|
|
50
81
|
function bootstrapAdminScripts() {
|
|
51
82
|
if (bootstrapped) {
|
|
52
|
-
return { scriptFactory, integrationFactory };
|
|
83
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
53
84
|
}
|
|
54
85
|
bootstrapped = true;
|
|
55
86
|
|
|
@@ -72,14 +103,26 @@ function bootstrapAdminScripts() {
|
|
|
72
103
|
);
|
|
73
104
|
}
|
|
74
105
|
|
|
106
|
+
// Built in its own try so a command-layer failure never blocks script
|
|
107
|
+
// registration (and vice versa) — both honor the never-throw contract.
|
|
108
|
+
try {
|
|
109
|
+
scriptCommands = buildScriptCommands();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(
|
|
112
|
+
'[admin-scripts] bootstrap: could not build command bundle:',
|
|
113
|
+
error.message
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
integrationFactory = createIntegrationFactory();
|
|
76
|
-
return { scriptFactory, integrationFactory };
|
|
118
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
77
119
|
}
|
|
78
120
|
|
|
79
121
|
/** Test-only: reset memoized bootstrap state. */
|
|
80
122
|
function _resetBootstrapForTests() {
|
|
81
123
|
bootstrapped = false;
|
|
82
124
|
scriptFactory = null;
|
|
125
|
+
scriptCommands = null;
|
|
83
126
|
integrationFactory = null;
|
|
84
127
|
}
|
|
85
128
|
|
|
@@ -8,7 +8,7 @@ const { bootstrapAdminScripts } = require('./bootstrap');
|
|
|
8
8
|
* Run a single execution message through the ScriptRunner.
|
|
9
9
|
* @param {Object} message - Parsed execution message.
|
|
10
10
|
* @param {string} message.scriptName - Name of the registered script to run (required).
|
|
11
|
-
* @param {string} [message.executionId] - Existing
|
|
11
|
+
* @param {string} [message.executionId] - Existing AdminScriptExecution id to resume; when
|
|
12
12
|
* absent, ScriptRunner creates a new record.
|
|
13
13
|
* @param {string} [message.trigger] - Execution trigger; defaults to 'QUEUE'.
|
|
14
14
|
* @param {Object} [message.params] - Parameters passed to the script.
|
|
@@ -16,10 +16,14 @@ const { bootstrapAdminScripts } = require('./bootstrap');
|
|
|
16
16
|
* @param {Object} deps
|
|
17
17
|
* @param {ScriptFactory} deps.scriptFactory - Registry used to resolve and instantiate the script.
|
|
18
18
|
* @param {Object} deps.integrationFactory - Hydrates integration instances for scripts that need them.
|
|
19
|
+
* @param {Object} deps.scriptCommands - Frigg command bundle exposed to the script as context.commands.
|
|
19
20
|
* @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
|
|
20
21
|
* @private
|
|
21
22
|
*/
|
|
22
|
-
async function runMessage(
|
|
23
|
+
async function runMessage(
|
|
24
|
+
message,
|
|
25
|
+
{ scriptFactory, integrationFactory, scriptCommands }
|
|
26
|
+
) {
|
|
23
27
|
const { scriptName, executionId, trigger, params, parentExecutionId } =
|
|
24
28
|
message;
|
|
25
29
|
|
|
@@ -33,7 +37,11 @@ async function runMessage(message, { scriptFactory, integrationFactory }) {
|
|
|
33
37
|
}`
|
|
34
38
|
);
|
|
35
39
|
|
|
36
|
-
const runner = createScriptRunner({
|
|
40
|
+
const runner = createScriptRunner({
|
|
41
|
+
scriptFactory,
|
|
42
|
+
integrationFactory,
|
|
43
|
+
scriptCommands,
|
|
44
|
+
});
|
|
37
45
|
const result = await runner.execute(scriptName, params, {
|
|
38
46
|
trigger: trigger || 'QUEUE',
|
|
39
47
|
mode: 'async',
|
|
@@ -51,7 +59,7 @@ async function runMessage(message, { scriptFactory, integrationFactory }) {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
|
-
* Mark an admin
|
|
62
|
+
* Mark an admin script execution FAILED when the worker itself blows up (parse error,
|
|
55
63
|
* runner construction), so the record doesn't stay stuck in a non-terminal
|
|
56
64
|
* state. No-op when there is no execution id.
|
|
57
65
|
* @private
|
|
@@ -60,7 +68,7 @@ async function markFailed(executionId, error) {
|
|
|
60
68
|
if (!executionId) return;
|
|
61
69
|
try {
|
|
62
70
|
const commands = createAdminScriptCommands();
|
|
63
|
-
await commands.
|
|
71
|
+
await commands.completeExecution(executionId, {
|
|
64
72
|
state: 'FAILED',
|
|
65
73
|
error: {
|
|
66
74
|
name: error.name,
|
|
@@ -80,7 +88,7 @@ async function markFailed(executionId, error) {
|
|
|
80
88
|
* Handle an EventBridge Scheduler direct invoke: the event itself is a single
|
|
81
89
|
* execution message (no `Records` wrapper).
|
|
82
90
|
* @param {Object} event - The execution message.
|
|
83
|
-
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
91
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
84
92
|
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
85
93
|
* @private
|
|
86
94
|
*/
|
|
@@ -118,7 +126,7 @@ async function handleScheduledInvoke(event, deps) {
|
|
|
118
126
|
* (manual async executions and queueScript continuations). Failures are isolated
|
|
119
127
|
* per record so one bad message doesn't drop the rest of the batch.
|
|
120
128
|
* @param {Object} event - The SQS event with a `Records` array.
|
|
121
|
-
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
129
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
122
130
|
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
123
131
|
* @private
|
|
124
132
|
*/
|