Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion .claude/skills/frigg/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: frigg
description: "Core reference and entry point for the Frigg integration framework: what Frigg is, hexagonal architecture and the golden rule, the integration definition pattern, the frigg CLI (install, start, build, deploy, doctor, repair, ui, generate-iam), AWS infrastructure (domain builders, scheduler, VPC, osls), field-level encryption, the Admin Script Runner (admin scripts, sync/async execution, chaining, scheduling), telemetry & usage tracking (OpenTelemetry, this.telemetry, Definition.usage, frigg.usage.*), the monorepo layout, and anti-patterns. Use when working in a Frigg project or repo (friggframework packages, IntegrationBase, infrastructure.js), understanding Frigg's architecture, configuring infrastructure/VPC/encryption/telemetry, adding observability or usage counters, or running frigg CLI commands. Links to the focused companion skills: frigg-api-modules, frigg-management-api, frigg-user-actions, and frigg-development-best-practices."
description: "Core reference and entry point for the Frigg integration framework: what Frigg is, hexagonal architecture and the golden rule, the integration definition pattern, the frigg CLI (install, start, build, deploy, doctor, repair, ui, generate-iam), AWS infrastructure (domain builders, scheduler, VPC, osls), field-level encryption, the Admin Script Runner (admin scripts, sync/async execution, chaining, scheduling), reporting as an admin operation (ReportBase, run modes live/recorded/snapshot, artifacts, scheduling), telemetry & usage tracking (OpenTelemetry, this.telemetry, Definition.usage, frigg.usage.*), the monorepo layout, and anti-patterns. Use when working in a Frigg project or repo (friggframework packages, IntegrationBase, infrastructure.js), understanding Frigg's architecture, configuring infrastructure/VPC/encryption/telemetry, adding observability or usage counters, or running frigg CLI commands. Links to the focused companion skills: frigg-api-modules, frigg-management-api, frigg-user-actions, and frigg-development-best-practices."
---

# Frigg Integration Framework Expert
Expand Down Expand Up @@ -98,6 +98,65 @@ A script extends `AdminScriptBase` with a static `Definition` (name, version, `i
- **When to use it:** work that won't fit one execution — beat the 15-min executor cap by paging/resuming; fan out one child per item/batch; isolate per-item failures; stage pipelines (A queues B with its output). For small bounded work, or when you need the result in the response, just use one sync/async execution.
- **Caveats:** fire-and-forget (you don't get the child's result back — correlate via `parentExecutionId`); at-least-once delivery, so **make child scripts idempotent**; and there is **no depth guard**, so keep continuation targets terminal or a self-queuing script fans out unbounded.

## Reports (ADR-010)

A **report is an admin operation whose output is its payload** — a sibling of admin scripts on the same runner, admin API key (`ADMIN_API_KEY`), async/SQS execution, and EventBridge scheduling. **Core ships built-in reports; adopters register their own.** Register with `reports: [MyReport]` in the app definition (+ `admin: { includeBuiltinReports: true }` for the core built-ins, e.g. `integrations`). A report extends `ReportBase` (from `@friggframework/core`) with a static `Definition` and `async execute(frigg, params)`, where `frigg` is the same command bundle scripts get as `context.commands` — **reads go through it, never a repository**.

- **Run modes** (`Definition.runModes`, first is default): **`live`** computes inline and persists nothing (cheap, always-fresh); **`recorded`** persists an execution record (input/results/logs) you poll async; **`snapshot`** is a recorded run tagged into a named series (trends). All three write to the isolated `AdminScriptExecution` store as `type: 'REPORT'` (no user/integration FK), so a user-scoped query can never return a report record.
- **Endpoints** (auth: `x-frigg-admin-api-key`): `GET /api/v2/reports` (list), `GET /api/v2/reports/{name}` (definition), `POST /api/v2/reports/{name}/run` with `{ mode, params }` (**`live` → 200 inline**; **`recorded`/`snapshot` → 202 `{ executionId }`**, queued to the dedicated `ReportQueue`), `GET .../{name}/snapshots?from=&to=` (the series), `GET .../executions/{id}`, and `GET|PUT|DELETE .../{name}/schedule`.
- **Output**: `output.format: 'json'` returns inline; `'csv'|'pdf'|'zip'` is written to artifact storage (S3, private + SSE, retrieved via signed URL) with a `{ summary, artifact }` on the record — non-JSON therefore runs `recorded`/`snapshot`, not `live`. Set `REPORT_ARTIFACT_BUCKET` (the infra provisions it when a non-JSON report is registered).
- **Scheduling** reuses the admin scheduler; a scheduled run targets the report executor with `{ reportName, mode: schedule.mode || 'snapshot', trigger: 'SCHEDULED' }`. Report and script names share one namespace (bootstrap rejects collisions). **A `schedule` block in the Definition only supplies the default scheduled *mode*; the recurring trigger is activated via `PUT /:name/schedule` — the DB schedule is the single source of truth (a declared `enabled`/`cron` does not fire on its own).**

**Example 1 — adopter report, snapshot + daily schedule, reads via `frigg`:**

```javascript
const { ReportBase } = require('@friggframework/core');

class ConnectedAccountsActivity extends ReportBase {
static Definition = {
name: 'connected-accounts-activity',
version: '1.0.0',
runModes: ['snapshot', 'recorded', 'live'], // first = default
inputSchema: { type: 'object', properties: {
windowDays: { type: 'integer', enum: [30, 60, 90], default: 30 } } },
output: { format: 'json' },
schedule: { enabled: true, cron: 'cron(0 6 * * ? *)', mode: 'snapshot' }, // default mode; activate via PUT .../schedule
};

async execute(frigg, params) { // frigg === context.commands
const since = new Date(Date.now() - (params.windowDays ?? 30) * 864e5);
const byType = await frigg.credentials.countActiveByType({ since });
return { windowDays: params.windowDays ?? 30, byType };
}
}
// POST /api/v2/reports/connected-accounts-activity/run {"mode":"snapshot"} → 202 {executionId}
// GET /api/v2/reports/connected-accounts-activity/snapshots?from=&to= → the daily trend
```

**Example 2 — CSV export to artifact storage (recorded), non-JSON output:**

```javascript
class ContactExportReport extends ReportBase {
static Definition = {
name: 'contact-export',
version: '1.0.0',
runModes: ['recorded'], // non-JSON can't run live
inputSchema: { type: 'object', properties: { type: { type: 'string' } } },
output: { format: 'csv' }, // → S3 + signed URL
};

async execute(frigg, params) {
const rows = await frigg.integrations.listForReport({ type: params.type });
const csv = ['id,type,status', ...rows.map((r) => `${r.id},${r.type},${r.status}`)].join('\n');
// Non-JSON reports return { file, summary, fileType }; the runner stores the
// file and records { summary, artifact } — retrieve via GET .../executions/{id}.
return { file: csv, summary: { rows: rows.length }, fileType: 'csv' };
}
}
```

The built-in `integrations` report (PR #607) is itself a `ReportBase` in core; use it as the reference implementation. Full guide: `packages/core/reporting/README.md`.

## Telemetry & Usage (ADR-011)

Vendor-neutral OpenTelemetry (traces + metrics) plus durable per-integration
Expand Down
14 changes: 14 additions & 0 deletions packages/admin-scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const {
ScriptRunner,
createScriptRunner,
} = require('./src/application/script-runner');
const {
ReportRunner,
createReportRunner,
} = require('./src/application/report-runner');

// Infrastructure
const {
Expand All @@ -26,9 +30,15 @@ const {
app,
handler: routerHandler,
} = require('./src/infrastructure/admin-script-router');
const {
handler: reportRouterHandler,
} = require('./src/infrastructure/report-router');
const {
handler: executorHandler,
} = require('./src/infrastructure/script-executor-handler');
const {
handler: reportExecutorHandler,
} = require('./src/infrastructure/report-executor-handler');

// Adapters
const { SchedulerAdapter } = require('./src/adapters/scheduler-adapter');
Expand All @@ -48,13 +58,17 @@ module.exports = {
createAdminScriptContext,
ScriptRunner,
createScriptRunner,
ReportRunner,
createReportRunner,

// Infrastructure layer
validateAdminApiKey,
router,
app,
routerHandler,
reportRouterHandler,
executorHandler,
reportExecutorHandler,

// Adapters
SchedulerAdapter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,60 @@ describe('AWSSchedulerAdapter', () => {
});
});

describe('report parameterization (namePrefix + buildInput)', () => {
const reportParams = {
targetLambdaArn:
'arn:aws:lambda:us-east-1:123456789012:function:report-executor',
scheduleGroupName: 'frigg-admin-scripts',
roleArn: 'arn:aws:iam::123456789012:role/test-role',
namePrefix: 'frigg-report-',
buildInput: ({ input }) => ({
reportName: 'integrations',
mode: 'snapshot',
trigger: 'SCHEDULED',
params: input || {},
}),
};

it('targets the report executor and emits a report-shaped message', async () => {
const reportAdapter = new AWSSchedulerAdapter({ ...reportParams });
mockSend.mockResolvedValue({
ScheduleArn:
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-report-integrations',
});

const result = await reportAdapter.createSchedule({
scriptName: 'integrations',
cronExpression: 'cron(0 6 * * ? *)',
});

expect(result.scheduleName).toBe('frigg-report-integrations');

const command = mockSend.mock.calls[0][0];
expect(command.params.Name).toBe('frigg-report-integrations');
expect(command.params.Target.Arn).toBe(
'arn:aws:lambda:us-east-1:123456789012:function:report-executor'
);
expect(JSON.parse(command.params.Target.Input)).toEqual({
reportName: 'integrations',
mode: 'snapshot',
trigger: 'SCHEDULED',
params: {},
});
});

it('deletes the report schedule by its prefixed name', async () => {
const reportAdapter = new AWSSchedulerAdapter({ ...reportParams });
mockSend.mockResolvedValue({});

await reportAdapter.deleteSchedule('integrations');

const command = mockSend.mock.calls[0][0];
expect(command._type).toBe('DeleteScheduleCommand');
expect(command.params.Name).toBe('frigg-report-integrations');
});
});

describe('Lazy SDK loading', () => {
it('should load AWS SDK on first client access', () => {
const newAdapter = new AWSSchedulerAdapter({ ...defaultParams });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const {
createSchedulerAdapter,
createSchedulerAdapterFromEnv,
createReportSchedulerAdapterFromEnv,
} = require('../scheduler-adapter-factory');
const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter');
const { LocalSchedulerAdapter } = require('../local-scheduler-adapter');
Expand Down Expand Up @@ -185,4 +186,52 @@ describe('Scheduler Adapter Factory', () => {
expect(adapter.roleArn).toBe(awsAdapterParams.roleArn);
});
});

describe('createReportSchedulerAdapterFromEnv()', () => {
it('builds an AWS adapter targeting the report executor with a report-shaped message', () => {
process.env.SCHEDULER_PROVIDER = 'aws';
process.env.REPORT_EXECUTOR_LAMBDA_ARN =
'arn:aws:lambda:us-east-1:123456789012:function:report-executor';
process.env.REPORT_SCHEDULE_GROUP = 'frigg-admin-scripts';
process.env.SCHEDULER_ROLE_ARN = awsAdapterParams.roleArn;

const adapter = createReportSchedulerAdapterFromEnv({
reportName: 'integrations',
mode: 'snapshot',
});

expect(adapter).toBeInstanceOf(AWSSchedulerAdapter);
expect(adapter.targetLambdaArn).toBe(
'arn:aws:lambda:us-east-1:123456789012:function:report-executor'
);
expect(adapter.scheduleNameFor('integrations')).toBe(
'frigg-report-integrations'
);
expect(adapter.buildInput({ input: { a: 1 } })).toEqual({
reportName: 'integrations',
mode: 'snapshot',
trigger: 'SCHEDULED',
params: { a: 1 },
});
});

it('throws 503 when SCHEDULER_PROVIDER is unset in a deployed Lambda', () => {
delete process.env.SCHEDULER_PROVIDER;
process.env.AWS_LAMBDA_FUNCTION_NAME = 'report-router';

let error;
try {
createReportSchedulerAdapterFromEnv({
reportName: 'x',
mode: 'snapshot',
});
} catch (e) {
error = e;
}

expect(error).toBeDefined();
expect(error.isBoom).toBe(true);
expect(error.output.statusCode).toBe(503);
});
});
});
30 changes: 21 additions & 9 deletions packages/admin-scripts/src/adapters/aws-scheduler-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ function loadSchedulerSDK() {
* Implements scheduling using AWS EventBridge Scheduler.
* Supports cron expressions, timezone configuration, and Lambda invocation.
*/
// Prefix and input builder are parameterized so one adapter can target either the script or report executor.
const DEFAULT_NAME_PREFIX = 'frigg-script-';
const defaultBuildInput = ({ scriptName, input }) => ({
scriptName,
trigger: 'SCHEDULED',
params: input || {},
});

class AWSSchedulerAdapter extends SchedulerAdapter {
constructor({
credentials,
targetLambdaArn,
scheduleGroupName,
roleArn,
namePrefix,
buildInput,
} = {}) {
super();
if (!targetLambdaArn)
Expand All @@ -52,9 +62,15 @@ class AWSSchedulerAdapter extends SchedulerAdapter {
this.targetLambdaArn = targetLambdaArn;
this.scheduleGroupName = scheduleGroupName;
this.roleArn = roleArn;
this.namePrefix = namePrefix || DEFAULT_NAME_PREFIX;
this.buildInput = buildInput || defaultBuildInput;
this.scheduler = null;
}

scheduleNameFor(scriptName) {
return `${this.namePrefix}${scriptName}`;
}

getSchedulerClient() {
if (!this.scheduler) {
loadSchedulerSDK();
Expand All @@ -72,7 +88,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter {

async createSchedule({ scriptName, cronExpression, timezone, input }) {
const client = this.getSchedulerClient();
const scheduleName = `frigg-script-${scriptName}`;
const scheduleName = this.scheduleNameFor(scriptName);

const scheduleParams = {
Name: scheduleName,
Expand All @@ -83,11 +99,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter {
Target: {
Arn: this.targetLambdaArn,
RoleArn: this.roleArn,
Input: JSON.stringify({
scriptName,
trigger: 'SCHEDULED',
params: input || {},
}),
Input: JSON.stringify(this.buildInput({ scriptName, input })),
},
State: 'ENABLED',
};
Expand Down Expand Up @@ -116,7 +128,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter {

async deleteSchedule(scriptName) {
const client = this.getSchedulerClient();
const scheduleName = `frigg-script-${scriptName}`;
const scheduleName = this.scheduleNameFor(scriptName);

await client.send(
new DeleteScheduleCommand({
Expand All @@ -128,7 +140,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter {

async setScheduleEnabled(scriptName, enabled) {
const client = this.getSchedulerClient();
const scheduleName = `frigg-script-${scriptName}`;
const scheduleName = this.scheduleNameFor(scriptName);

// Get the current schedule first to preserve all settings
const getCommand = new GetScheduleCommand({
Expand Down Expand Up @@ -167,7 +179,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter {

async getSchedule(scriptName) {
const client = this.getSchedulerClient();
const scheduleName = `frigg-script-${scriptName}`;
const scheduleName = this.scheduleNameFor(scriptName);

const response = await client.send(
new GetScheduleCommand({
Expand Down
40 changes: 40 additions & 0 deletions packages/admin-scripts/src/adapters/scheduler-adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ function createSchedulerAdapter(options = {}) {
targetLambdaArn: options.targetLambdaArn,
scheduleGroupName: options.scheduleGroupName,
roleArn: options.roleArn,
namePrefix: options.namePrefix,
buildInput: options.buildInput,
});

case 'local':
Expand Down Expand Up @@ -79,7 +81,45 @@ function createSchedulerAdapterFromEnv() {
});
}

/**
* Resolve and build a scheduler adapter that targets the report executor Lambda.
*
* A distinct name prefix keeps report schedules from colliding with script
* schedules in the shared EventBridge group.
*
* @param {Object} params
* @param {string} params.reportName - Registered report name (also the ScriptSchedule key).
* @param {string} params.mode - Run mode for the scheduled invocation (e.g. 'snapshot').
* @returns {SchedulerAdapter}
* @throws {Boom.Boom} 503 when SCHEDULER_PROVIDER is unset in a deployed Lambda.
*/
function createReportSchedulerAdapterFromEnv({ reportName, mode }) {
const type =
process.env.SCHEDULER_PROVIDER ||
(process.env.AWS_LAMBDA_FUNCTION_NAME ? null : 'local');
if (!type) {
throw Boom.serverUnavailable(
'SCHEDULER_PROVIDER is not configured. Set it (e.g. "aws") via appDefinition.admin.enableScheduling.'
);
}

return createSchedulerAdapter({
type,
targetLambdaArn: process.env.REPORT_EXECUTOR_LAMBDA_ARN,
scheduleGroupName: process.env.REPORT_SCHEDULE_GROUP,
roleArn: process.env.SCHEDULER_ROLE_ARN,
namePrefix: 'frigg-report-',
buildInput: ({ input }) => ({
reportName,
mode,
trigger: 'SCHEDULED',
params: input || {},
}),
});
}

module.exports = {
createSchedulerAdapter,
createSchedulerAdapterFromEnv,
createReportSchedulerAdapterFromEnv,
};
Loading
Loading