@friggframework/admin-scripts 2.0.0-next.103 → 2.0.0-next.105

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.
@@ -1,22 +1,26 @@
1
1
  const { ScriptFactory } = require('../application/script-factory');
2
2
 
3
3
  /**
4
- * Admin Script Bootstrap
4
+ * Admin Operations Bootstrap
5
5
  *
6
6
  * Composition root for the admin-scripts runtime. Loads the host app's
7
- * definition at Lambda runtime, builds a ScriptFactory, and registers the app's
8
- * admin scripts into it so the router and SQS worker can resolve scripts by
9
- * name. Also constructs the integrationFactory used by scripts that need
10
- * hydrated integration instances. Both are returned for the caller to inject.
7
+ * definition at Lambda runtime and builds two name-keyed registries one for
8
+ * admin scripts, one for reports plus the command bundles they need, so the
9
+ * routers and SQS workers can resolve operations by name. The registry class is
10
+ * shared (ScriptFactory is generic over `static Definition.name`); reports are a
11
+ * second instance rather than a duplicate class.
11
12
  *
12
13
  * Runs once per process (memoized) and never throws — a missing/unloadable app
13
- * definition is logged and leaves the factory empty rather than crashing the
14
+ * definition is logged and leaves the registries empty rather than crashing the
14
15
  * Lambda cold start.
15
16
  */
16
17
  let bootstrapped = false;
17
18
  let scriptFactory = null;
18
19
  let scriptCommands = null;
19
20
  let integrationFactory = null;
21
+ let reportFactory = null;
22
+ let reportCommands = null;
23
+ let reportFriggCommands = null;
20
24
 
21
25
  function registerScripts(factory, scriptClasses) {
22
26
  for (const ScriptClass of scriptClasses || []) {
@@ -28,6 +32,24 @@ function registerScripts(factory, scriptClasses) {
28
32
  }
29
33
  }
30
34
 
35
+ // A report whose name collides with a registered script is skipped: scripts and
36
+ // reports share ScriptSchedule.scriptName (@unique), so names must not clash.
37
+ function registerReports(factory, scriptRegistry, reportClasses) {
38
+ for (const ReportClass of reportClasses || []) {
39
+ const name = ReportClass?.Definition?.name;
40
+ if (!name) continue;
41
+ if (scriptRegistry.has(name)) {
42
+ console.error(
43
+ `[admin-scripts] bootstrap: report "${name}" collides with a registered script name; skipping.`
44
+ );
45
+ continue;
46
+ }
47
+ if (!factory.has(name)) {
48
+ factory.register(ReportClass);
49
+ }
50
+ }
51
+ }
52
+
31
53
  /**
32
54
  * Build an integration factory backed by the framework's runtime hydration.
33
55
  * Scripts call `context.instantiate(integrationId)` which delegates here.
@@ -46,7 +68,7 @@ function createIntegrationFactory() {
46
68
  }
47
69
 
48
70
  /**
49
- * Build the command bundle injected into every AdminScriptContext. Scripts
71
+ * Build the command bundle injected into every AdminScriptContext. Operations
50
72
  * interact with the database only through these Frigg commands — never through
51
73
  * repositories directly. The require is lazy so the package keeps no load-time
52
74
  * coupling to core's command/repository modules.
@@ -75,27 +97,62 @@ function buildScriptCommands() {
75
97
  };
76
98
  }
77
99
 
100
+ function buildReportFriggCommands() {
101
+ const {
102
+ createIntegrationMappingCommands,
103
+ } = require('@friggframework/core/application/commands/integration-mapping-commands');
104
+ const {
105
+ createUsageCommands,
106
+ } = require('@friggframework/core/application/commands/usage-commands');
107
+
108
+ return {
109
+ ...buildScriptCommands(),
110
+ integrationMappings: createIntegrationMappingCommands(),
111
+ usage: createUsageCommands(),
112
+ };
113
+ }
114
+
78
115
  /**
79
- * @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
116
+ * @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object, reportFactory: ScriptFactory, reportCommands: object, reportFriggCommands: object }}
80
117
  */
81
118
  function bootstrapAdminScripts() {
82
119
  if (bootstrapped) {
83
- return { scriptFactory, scriptCommands, integrationFactory };
120
+ return {
121
+ scriptFactory,
122
+ scriptCommands,
123
+ integrationFactory,
124
+ reportFactory,
125
+ reportCommands,
126
+ reportFriggCommands,
127
+ };
84
128
  }
85
129
  bootstrapped = true;
86
130
 
87
- // Create the registry up front so consumers always get a (possibly empty)
88
- // factory even when the app definition can't be loaded — mirrors the
131
+ // Create the registries up front so consumers always get (possibly empty)
132
+ // factories even when the app definition can't be loaded — mirrors the
89
133
  // never-throw contract above.
90
134
  scriptFactory = new ScriptFactory();
135
+ reportFactory = new ScriptFactory();
91
136
 
92
137
  try {
93
138
  const {
94
139
  loadAppDefinition,
95
140
  } = require('@friggframework/core/handlers/app-definition-loader');
96
- const { adminScripts = [] } = loadAppDefinition();
141
+ const {
142
+ adminScripts = [],
143
+ reports = [],
144
+ admin = {},
145
+ } = loadAppDefinition();
97
146
 
98
147
  registerScripts(scriptFactory, adminScripts);
148
+ registerReports(reportFactory, scriptFactory, reports);
149
+
150
+ if (admin.includeBuiltinReports) {
151
+ const {
152
+ BUILTIN_REPORTS,
153
+ } = require('@friggframework/core/reporting/builtin-reports');
154
+ registerReports(reportFactory, scriptFactory, BUILTIN_REPORTS);
155
+ }
99
156
  } catch (error) {
100
157
  console.error(
101
158
  '[admin-scripts] bootstrap: could not load app definition:',
@@ -103,8 +160,8 @@ function bootstrapAdminScripts() {
103
160
  );
104
161
  }
105
162
 
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.
163
+ // Built in their own try so a command-layer failure never blocks operation
164
+ // registration (and vice versa) — all honor the never-throw contract.
108
165
  try {
109
166
  scriptCommands = buildScriptCommands();
110
167
  } catch (error) {
@@ -114,8 +171,28 @@ function bootstrapAdminScripts() {
114
171
  );
115
172
  }
116
173
 
174
+ try {
175
+ reportFriggCommands = buildReportFriggCommands();
176
+ const {
177
+ createReportCommands,
178
+ } = require('@friggframework/core/application/commands/report-commands');
179
+ reportCommands = createReportCommands();
180
+ } catch (error) {
181
+ console.error(
182
+ '[admin-scripts] bootstrap: could not build report commands:',
183
+ error.message
184
+ );
185
+ }
186
+
117
187
  integrationFactory = createIntegrationFactory();
118
- return { scriptFactory, scriptCommands, integrationFactory };
188
+ return {
189
+ scriptFactory,
190
+ scriptCommands,
191
+ integrationFactory,
192
+ reportFactory,
193
+ reportCommands,
194
+ reportFriggCommands,
195
+ };
119
196
  }
120
197
 
121
198
  /** Test-only: reset memoized bootstrap state. */
@@ -124,6 +201,9 @@ function _resetBootstrapForTests() {
124
201
  scriptFactory = null;
125
202
  scriptCommands = null;
126
203
  integrationFactory = null;
204
+ reportFactory = null;
205
+ reportCommands = null;
206
+ reportFriggCommands = null;
127
207
  }
128
208
 
129
209
  module.exports = {
@@ -0,0 +1,202 @@
1
+ const { createReportRunner } = require('../application/report-runner');
2
+ const {
3
+ createReportCommands,
4
+ } = require('@friggframework/core/application/commands/report-commands');
5
+ const { QueuerUtil } = require('@friggframework/core/queues');
6
+ const { bootstrapAdminScripts } = require('./bootstrap');
7
+
8
+ // Resume state travels in the message (params.__resume); the execution record
9
+ // stays RUNNING across hops so the same execution resumes.
10
+ async function requeueContinuation(message, result) {
11
+ const queueUrl = process.env.REPORT_QUEUE_URL;
12
+ if (!queueUrl) {
13
+ throw new Error(
14
+ 'Report yielded a continuation but REPORT_QUEUE_URL is not set; cannot resume'
15
+ );
16
+ }
17
+
18
+ await QueuerUtil.send(
19
+ {
20
+ reportName: message.reportName,
21
+ executionId: result.executionId,
22
+ mode: message.mode || 'recorded',
23
+ ...(message.seriesName && { seriesName: message.seriesName }),
24
+ trigger: 'QUEUE',
25
+ params: { ...(message.params || {}), __resume: result.continuation },
26
+ resumeAt: new Date().toISOString(),
27
+ },
28
+ queueUrl
29
+ );
30
+ }
31
+
32
+ async function runMessage(
33
+ message,
34
+ { reportFactory, reportCommands, reportFriggCommands, integrationFactory },
35
+ lambdaContext
36
+ ) {
37
+ const {
38
+ reportName,
39
+ executionId,
40
+ mode,
41
+ seriesName,
42
+ trigger,
43
+ params,
44
+ parentExecutionId,
45
+ } = message;
46
+
47
+ if (!reportName) {
48
+ throw new Error('Invalid message: missing reportName');
49
+ }
50
+
51
+ console.log(
52
+ `Processing report: ${reportName}${
53
+ executionId ? `, executionId: ${executionId}` : ''
54
+ }`
55
+ );
56
+
57
+ const runner = createReportRunner({
58
+ reportFactory,
59
+ reportCommands,
60
+ friggCommands: reportFriggCommands,
61
+ integrationFactory,
62
+ });
63
+ const result = await runner.execute(reportName, params, {
64
+ mode: mode || 'recorded',
65
+ trigger: trigger || 'QUEUE',
66
+ // Scheduled first runs carry no executionId; ReportRunner creates the record.
67
+ ...(executionId && { executionId }),
68
+ ...(seriesName && { seriesName }),
69
+ ...(parentExecutionId && { parentExecutionId }),
70
+ ...(lambdaContext && { lambdaContext }),
71
+ });
72
+
73
+ if (result.status === 'CONTINUE') {
74
+ // A failed re-enqueue means the execution never resumes; mark it FAILED
75
+ // (via the runner-created id) instead of leaving it stuck in RUNNING.
76
+ try {
77
+ await requeueContinuation(message, result);
78
+ } catch (requeueError) {
79
+ await markFailed(result.executionId, requeueError);
80
+ throw requeueError;
81
+ }
82
+ console.log(
83
+ `Report re-queued for continuation: ${reportName}, executionId: ${result.executionId}`
84
+ );
85
+ }
86
+
87
+ return {
88
+ reportName,
89
+ status: result.status,
90
+ executionId: result.executionId,
91
+ };
92
+ }
93
+
94
+ // Mark a report execution FAILED when the worker itself throws, so the record
95
+ // doesn't stay stuck in a non-terminal state.
96
+ async function markFailed(executionId, error) {
97
+ if (!executionId) return;
98
+ try {
99
+ const commands = createReportCommands();
100
+ await commands.completeExecution(executionId, {
101
+ state: 'FAILED',
102
+ error: {
103
+ name: error.name,
104
+ message: error.message,
105
+ stack: error.stack,
106
+ },
107
+ });
108
+ } catch (updateError) {
109
+ console.error(
110
+ `Failed to update report execution ${executionId} state:`,
111
+ updateError
112
+ );
113
+ }
114
+ }
115
+
116
+ // EventBridge Scheduler direct invoke: the event itself is the message (no `Records` wrapper).
117
+ async function handleScheduledInvoke(event, deps, lambdaContext) {
118
+ try {
119
+ const result = await runMessage(event, deps, lambdaContext);
120
+ console.log(
121
+ `Report completed: ${result.reportName}, status: ${result.status}`
122
+ );
123
+ return {
124
+ statusCode: 200,
125
+ body: JSON.stringify({ processed: 1, results: [result] }),
126
+ };
127
+ } catch (error) {
128
+ console.error('Unexpected error processing scheduled invoke:', error);
129
+ await markFailed(event.executionId, error);
130
+ return {
131
+ statusCode: 200,
132
+ body: JSON.stringify({
133
+ processed: 1,
134
+ results: [
135
+ {
136
+ reportName: event.reportName || 'unknown',
137
+ status: 'FAILED',
138
+ error: error.message,
139
+ },
140
+ ],
141
+ }),
142
+ };
143
+ }
144
+ }
145
+
146
+ // SQS batch: each record body is a JSON message. Failures are isolated per
147
+ // record so one bad message doesn't drop the rest of the batch.
148
+ async function handleSqsBatch(event, deps, lambdaContext) {
149
+ const results = [];
150
+ for (const record of event.Records) {
151
+ let message = {};
152
+ try {
153
+ message = JSON.parse(record.body);
154
+ const result = await runMessage(message, deps, lambdaContext);
155
+ console.log(
156
+ `Report completed: ${result.reportName}, status: ${result.status}`
157
+ );
158
+ results.push(result);
159
+ } catch (error) {
160
+ // Report execution errors are handled by ReportRunner; only
161
+ // unexpected failures (parse, runner construction) reach here.
162
+ console.error('Unexpected error processing record:', error);
163
+ await markFailed(message.executionId, error);
164
+ results.push({
165
+ reportName: message.reportName || 'unknown',
166
+ status: 'FAILED',
167
+ error: error.message,
168
+ });
169
+ }
170
+ }
171
+
172
+ return {
173
+ statusCode: 200,
174
+ body: JSON.stringify({ processed: results.length, results }),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Report Executor Lambda handler. Two invocation shapes:
180
+ * - SQS: `event.Records[]`, each body a JSON execution message.
181
+ * - EventBridge Scheduler direct invoke: the event itself is the message, no `Records`.
182
+ */
183
+ async function handler(event, context) {
184
+ const {
185
+ reportFactory,
186
+ reportCommands,
187
+ reportFriggCommands,
188
+ integrationFactory,
189
+ } = bootstrapAdminScripts();
190
+ const deps = {
191
+ reportFactory,
192
+ reportCommands,
193
+ reportFriggCommands,
194
+ integrationFactory,
195
+ };
196
+
197
+ return event.Records
198
+ ? handleSqsBatch(event, deps, context)
199
+ : handleScheduledInvoke(event, deps, context);
200
+ }
201
+
202
+ module.exports = { handler };