@aglyn/plugins-workflows 1.0.0-beta.222 → 1.0.0-beta.223
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/package.json +12 -12
- package/src/lib/components/automation-step-fields.component.d.ts +16 -2
- package/src/lib/components/automation-step-fields.component.js +53 -2
- package/src/lib/components/automation-step-fields.component.js.map +1 -1
- package/src/lib/components/automation-trigger-conditions.component.d.ts +23 -2
- package/src/lib/components/automation-trigger-conditions.component.js +89 -10
- package/src/lib/components/automation-trigger-conditions.component.js.map +1 -1
- package/src/lib/components/host-actions-card.component.js +64 -30
- package/src/lib/components/host-actions-card.component.js.map +1 -1
- package/src/lib/components/host-workflows-card.component.js +31 -10
- package/src/lib/components/host-workflows-card.component.js.map +1 -1
- package/src/lib/components/org-automation-editor.component.d.ts +1 -1
- package/src/lib/components/org-automation-editor.component.js +20 -11
- package/src/lib/components/org-automation-editor.component.js.map +1 -1
- package/src/lib/engine/email-merge.d.ts +69 -0
- package/src/lib/engine/email-merge.js +98 -0
- package/src/lib/engine/email-merge.js.map +1 -0
- package/src/lib/engine/run-event-actions.d.ts +30 -0
- package/src/lib/engine/run-event-actions.js +158 -54
- package/src/lib/engine/run-event-actions.js.map +1 -1
- package/src/lib/engine/run-event-workflows.js +44 -8
- package/src/lib/engine/run-event-workflows.js.map +1 -1
- package/src/lib/engine/run-org-automations.js +3 -1
- package/src/lib/engine/run-org-automations.js.map +1 -1
- package/src/lib/engine/triggered-docs.d.ts +39 -0
- package/src/lib/engine/triggered-docs.js +47 -0
- package/src/lib/engine/triggered-docs.js.map +1 -0
- package/src/lib/model/host-actions.d.ts +53 -0
- package/src/lib/model/host-actions.js +50 -2
- package/src/lib/model/host-actions.js.map +1 -1
- package/src/lib/model/org-automations.d.ts +6 -4
- package/src/lib/model/org-automations.js +8 -5
- package/src/lib/model/org-automations.js.map +1 -1
- package/src/lib/server-automation-drafts.js +2 -1
- package/src/lib/server-automation-drafts.js.map +1 -1
- package/src/lib/testing/stand-in-crm-steps.js +1 -1
- package/src/lib/testing/stand-in-crm-steps.js.map +1 -1
- package/src/lib/testing/stand-in-recipes.d.ts +3 -2
- package/src/lib/testing/stand-in-recipes.js +17 -12
- package/src/lib/testing/stand-in-recipes.js.map +1 -1
|
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { checkEntitlement, evaluateExpression, resolveOrgEntitlements } from "@aglyn/aglyn/server";
|
|
18
18
|
import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
|
|
19
|
+
import { triggerFilterProblem } from "@aglyn/aglyn/app-utils/site-interactions";
|
|
19
20
|
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
20
21
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
22
|
import { runSummaryFields } from "../model/run-history.js";
|
|
@@ -23,11 +24,17 @@ import { ACTION_MAX_EVENT_DEPTH, runWorkflow } from "../model/workflows.js";
|
|
|
23
24
|
import { eventRunSuspension } from "./site-suspension.js";
|
|
24
25
|
import { recordRuns, runMonthKey, runsUsedThisMonth } from "./run-meter.js";
|
|
25
26
|
import { deferFlowEnrollment, endFlowEnrollment } from "./flow-enrollments.js";
|
|
26
|
-
import { automationRunEnv, executeWorkflow, FLOW_CLAIM_RETRY_MS, stopFlowEnrollment } from "./run-event-actions.js";
|
|
27
|
+
import { automationRunEnv, executeWorkflow, FLOW_CLAIM_RETRY_MS, SKIP_LOG_EXCLUDED_EVENTS, stopFlowEnrollment } from "./run-event-actions.js";
|
|
27
28
|
import { workflowHasActionSteps } from "./workflow-steps.js";
|
|
28
29
|
import { runTriggeredByFields } from "./run-trigger-actor.js";
|
|
30
|
+
import { triggeredDocsForEvent } from "./triggered-docs.js";
|
|
29
31
|
import { FLOW_TIMED_OUT_FIELD } from "../model/host-actions.js";
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Every live workflow on the event runs, in document-id order, up to this
|
|
34
|
+
* many (AGL-3458) — the hundred the workflow map below already reads, so a
|
|
35
|
+
* workflow the map can see is one its event can start. See
|
|
36
|
+
* `triggered-docs.ts` for why the dispatch pages rather than truncates.
|
|
37
|
+
*/ const MAX_TRIGGERED_WORKFLOWS = 100;
|
|
31
38
|
/**
|
|
32
39
|
* One workflow run's row in the site's activity feed, which is the run
|
|
33
40
|
* history the Workflows tab reads.
|
|
@@ -95,8 +102,11 @@ import { FLOW_TIMED_OUT_FIELD } from "../model/host-actions.js";
|
|
|
95
102
|
try {
|
|
96
103
|
const firestore = firebaseAdmin.app().firestore();
|
|
97
104
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
98
|
-
const
|
|
99
|
-
|
|
105
|
+
const workflows = await triggeredDocsForEvent(hostRef.collection('workflows'), event, {
|
|
106
|
+
keep: (doc)=>!doc.get('deletedAt'),
|
|
107
|
+
max: MAX_TRIGGERED_WORKFLOWS,
|
|
108
|
+
maxReads: MAX_TRIGGERED_WORKFLOWS * 4
|
|
109
|
+
});
|
|
100
110
|
if (!workflows.length) return alerts;
|
|
101
111
|
// Full workflow map for nested function→workflow calls (AGL-129).
|
|
102
112
|
const allWorkflowDocs = await hostRef.collection('workflows').limit(100).get();
|
|
@@ -165,18 +175,44 @@ import { FLOW_TIMED_OUT_FIELD } from "../model/host-actions.js";
|
|
|
165
175
|
const workflow = doc.data();
|
|
166
176
|
const filter = (_workflow_trigger = workflow.trigger) == null ? void 0 : (_workflow_trigger_filter = _workflow_trigger.filter) == null ? void 0 : _workflow_trigger_filter.trim();
|
|
167
177
|
if (filter) {
|
|
178
|
+
/*
|
|
179
|
+
* A filter no event can ever satisfy — a comparison the evaluator
|
|
180
|
+
* does not have, text it cannot read — answers "why didn't it run?"
|
|
181
|
+
* in the run history, on every event it would have run on, rather
|
|
182
|
+
* than leaving the workflow silent. The editor refuses to save one;
|
|
183
|
+
* this is the one stored before it did, or written around it.
|
|
184
|
+
*/ const unrunnable = triggerFilterProblem(filter, {
|
|
185
|
+
remedy: 'action'
|
|
186
|
+
});
|
|
187
|
+
if (unrunnable) {
|
|
188
|
+
if (!SKIP_LOG_EXCLUDED_EVENTS.has(event)) {
|
|
189
|
+
var _workflow_name1;
|
|
190
|
+
await recordWorkflowRun(hostRef, _extends({
|
|
191
|
+
action: `Workflow skipped on ${event}`,
|
|
192
|
+
result: 'skipped',
|
|
193
|
+
trigger: event
|
|
194
|
+
}, runSummaryFields(unrunnable.slice(0, 300)), {
|
|
195
|
+
target: {
|
|
196
|
+
type: 'workflow',
|
|
197
|
+
id: doc.id,
|
|
198
|
+
name: (_workflow_name1 = workflow.name) != null ? _workflow_name1 : ''
|
|
199
|
+
}
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
168
204
|
try {
|
|
169
205
|
const scope = _extends({
|
|
170
206
|
event
|
|
171
207
|
}, payload);
|
|
172
208
|
if (!evaluateExpression(filter, scope)) continue;
|
|
173
209
|
} catch (unused) {
|
|
174
|
-
continue; // A
|
|
210
|
+
continue; // A field the filter names is not on this event.
|
|
175
211
|
}
|
|
176
212
|
}
|
|
177
213
|
const startedAt = Date.now();
|
|
178
214
|
if (workflowHasActionSteps(workflow)) {
|
|
179
|
-
var
|
|
215
|
+
var _workflow_name2, _workflow_name3;
|
|
180
216
|
/*
|
|
181
217
|
* A WORKFLOW WITH ACTIONS STEPS is performed, not evaluated. The
|
|
182
218
|
* maps above are this run's working set, so the executor reads them
|
|
@@ -198,13 +234,13 @@ import { FLOW_TIMED_OUT_FIELD } from "../model/host-actions.js";
|
|
|
198
234
|
const execution = await executeWorkflow(env, {
|
|
199
235
|
kind: 'workflow',
|
|
200
236
|
id: doc.id,
|
|
201
|
-
name: (
|
|
237
|
+
name: (_workflow_name2 = workflow.name) != null ? _workflow_name2 : ''
|
|
202
238
|
}, workflow, event, payload);
|
|
203
239
|
executed += 1;
|
|
204
240
|
await recordWorkflowRun(hostRef, workflowRunRow(execution, {
|
|
205
241
|
event,
|
|
206
242
|
workflowId: doc.id,
|
|
207
|
-
workflowName: (
|
|
243
|
+
workflowName: (_workflow_name3 = workflow.name) != null ? _workflow_name3 : '',
|
|
208
244
|
durationMs: Date.now() - startedAt
|
|
209
245
|
}));
|
|
210
246
|
continue;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/run-event-workflows.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n evaluateExpression,\n type HostEventType,\n type HostFunction,\n type HostVariable,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport { activitySearchTokens } from '@aglyn/aglyn/app-utils/activity-search'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { runSummaryFields } from '../model/run-history'\nimport { ACTION_MAX_EVENT_DEPTH, type HostWorkflow, runWorkflow } from '../model/workflows'\nimport { eventRunSuspension } from './site-suspension'\nimport {\n recordRuns,\n type RunMeterScope,\n runMonthKey,\n runsUsedThisMonth,\n} from './run-meter'\nimport {\n deferFlowEnrollment,\n endFlowEnrollment,\n type FlowEnrollment,\n} from './flow-enrollments'\nimport {\n automationRunEnv,\n executeWorkflow,\n FLOW_CLAIM_RETRY_MS,\n stopFlowEnrollment,\n type WorkflowContext,\n type WorkflowExecution,\n} from './run-event-actions'\nimport {\n type AutomationWorkflow,\n workflowHasActionSteps,\n} from './workflow-steps'\nimport { runTriggeredByFields } from './run-trigger-actor'\nimport { FLOW_TIMED_OUT_FIELD, type HostActionAlert } from '../model/host-actions'\n\n/** Bounded fan-out per event: at most this many triggered workflows run. */\nconst MAX_TRIGGERED_WORKFLOWS = 10\n\n/**\n * One workflow run's row in the site's activity feed, which is the run\n * history the Workflows tab reads.\n *\n * The one place a workflow's run is written, whichever door started it.\n */\nasync function recordWorkflowRun(\n hostRef: FirebaseFirestore.DocumentReference,\n row: Record<string, unknown>,\n): Promise<void> {\n await hostRef\n .collection('activity')\n .add({\n actorId: null,\n actorEmail: null,\n // Who set this run off (AGL-3376).\n ...runTriggeredByFields(),\n ...row,\n // The site log's search finds a run by its workflow's name (AGL-3321).\n searchTokens: activitySearchTokens({\n target: row['target'] as { name?: unknown } | undefined,\n }),\n createdAt: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n}\n\n/**\n * The history row for a run of a workflow with Actions steps, in the Actions\n * run's phrasing (AGL-2171) — what each step did, joined — so the run table\n * shows every step's outcome whichever engine the step came from. `status`\n * and `durationMs` ride along as they do on every workflow run, for the\n * dashboard feed that renders them.\n */\nexport function workflowRunRow(\n execution: Pick<WorkflowExecution, 'ending' | 'errors' | 'outcomes'>,\n input: {\n event: string\n workflowId: string\n workflowName: string\n durationMs: number\n },\n): Record<string, unknown> {\n const failed = execution.errors.length > 0\n const action = failed\n ? `Workflow ran on ${input.event} with errors: ${execution.errors.join('; ')}`.slice(\n 0,\n 300,\n )\n : execution.ending === 'waiting'\n ? `Workflow is waiting, on ${input.event}`\n : `Workflow ran on ${input.event}`\n return {\n action,\n result: failed ? 'failed' : 'succeeded',\n trigger: input.event,\n // The summary and its search tokens, written together (AGL-3321).\n ...runSummaryFields(\n (execution.outcomes.length\n ? execution.outcomes.join(' · ')\n : 'Ran'\n ).slice(0, 300),\n ),\n status: failed ? 'error' : 'ok',\n durationMs: input.durationMs,\n target: {\n type: 'workflow',\n id: input.workflowId,\n name: input.workflowName,\n },\n }\n}\n\n/**\n * Event-triggered workflow runner (AGL-128): loads workflows whose\n * `trigger.event` matches, evaluates optional trigger filters over the\n * event payload, runs each, and logs runs to the host activity feed.\n * Fire-and-forget from the tenant's event sites (form submit, pageview\n * collector, membership APIs) — it never throws, and a failure must not\n * break the request that emitted the event.\n *\n * A workflow of function calls runs through the pure `runWorkflow`\n * evaluator exactly as it always has. A workflow with Actions steps runs\n * through `executeWorkflow`, which performs them with the Actions executors;\n * the site alerts its steps raise are what this resolves.\n *\n * ONE RUN, METERED ONCE: either kind counts one run on the workflow meter,\n * `workflowRunsPerMonth`, however many steps it has and whatever they are.\n * Its Actions steps take the Actions tier gates step by step, and none of\n * them counts an action run.\n *\n * `depth` is the chain guard actions run under (`ACTION_MAX_EVENT_DEPTH`):\n * an event a workflow's own step raised — a stage it set — is heard one\n * level deeper, so a workflow that sets the stage it listens for stops.\n */\nexport async function runEventWorkflows(\n hostId: string,\n event: HostEventType,\n payload: HostEventPayload = {},\n depth = 0,\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n if (depth > ACTION_MAX_EVENT_DEPTH) return alerts\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const triggered = await hostRef\n .collection('workflows')\n .where('trigger.event', '==', event)\n .limit(MAX_TRIGGERED_WORKFLOWS)\n .get()\n const workflows = triggered.docs.filter((doc) => !doc.get('deletedAt'))\n if (!workflows.length) return alerts\n // Full workflow map for nested function→workflow calls (AGL-129).\n const allWorkflowDocs = await hostRef\n .collection('workflows')\n .limit(100)\n .get()\n // Double-keyed by doc id AND name (AGL-261): id references are\n // rename-safe; legacy name references keep resolving.\n // Each carries its id, so a workflow a step names by its legacy name\n // still runs as the automation it is (see `WorkflowContext`).\n const workflowMap: WorkflowContext['workflows'] = {}\n for (const doc of allWorkflowDocs.docs) {\n const data = doc.data() as any\n if (data.deletedAt) continue\n const withId = { ...data, $id: doc.id }\n workflowMap[doc.id] = withId\n if (data?.name) workflowMap[data.name] = withId\n }\n\n // Monthly run cap by the owning org's plan (AGL-165).\n // Run caps ride the owning org's doc (AGL-238) — read through\n // `getOrgForHost` below. The host document itself is not consulted: the\n // cap, the counter and the workflow definitions all live elsewhere, so a\n // read of it here would be billed on every host event and used for\n // nothing.\n const owner = await getOrgForHost(hostId)\n // The band is the WORKSPACE's (AGL-3472): every site's runs count\n // against it, read off the org's counter — see `run-meter.ts`.\n const meter: RunMeterScope = {\n firestore,\n hostRef,\n orgId: owner?.orgId,\n counter: 'workflowRuns',\n month: runMonthKey(),\n }\n // A suspended site runs nothing (AGL-3356): see `eventRunSuspension`.\n // Asked before the cap so a locked site's events bill no runs.\n if (await eventRunSuspension(hostRef, owner?.org)) return alerts\n {\n const org = owner?.org\n // Plan-less orgs resolve as free (AGL-247) — the cap always runs.\n const limit = resolveOrgEntitlements(\n org as any,\n ).workflowRunsPerMonth\n const used = await runsUsedThisMonth(meter)\n if (used + workflows.length > limit) return alerts\n }\n\n const [functionDocs, variableDocs] = await Promise.all([\n hostRef.collection('functions').limit(100).get(),\n hostRef.collection('variables').limit(100).get(),\n ])\n const functions: Record<string, HostFunction> = {}\n for (const doc of functionDocs.docs) {\n const definition = doc.data() as any\n if (definition.deletedAt) continue\n functions[doc.id] = definition\n if (definition.name) functions[definition.name] = definition\n }\n const variables: Record<string, HostVariable> = {}\n for (const doc of variableDocs.docs) {\n const variable = doc.data() as any\n if (!variable.deletedAt && variable.name) {\n variables[variable.name] = variable\n }\n }\n\n let executed = 0\n for (const doc of workflows) {\n const workflow = doc.data() as AutomationWorkflow\n const filter = workflow.trigger?.filter?.trim()\n if (filter) {\n try {\n const scope: HostEventPayload = { event, ...payload }\n if (!evaluateExpression(filter, scope)) continue\n } catch {\n continue // A broken filter never fires.\n }\n }\n const startedAt = Date.now()\n if (workflowHasActionSteps(workflow)) {\n /*\n * A WORKFLOW WITH ACTIONS STEPS is performed, not evaluated. The\n * maps above are this run's working set, so the executor reads them\n * rather than a second copy; the plan gates come off the org\n * document the cap already read.\n */\n const context: WorkflowContext = {\n functions,\n variables,\n workflows: workflowMap,\n }\n const env = automationRunEnv({\n hostId,\n hostRef,\n owner,\n depth,\n alerts,\n loadWorkflowContext: async () => context,\n })\n const execution = await executeWorkflow(\n env,\n { kind: 'workflow', id: doc.id, name: workflow.name ?? '' },\n workflow,\n event,\n payload,\n )\n executed += 1\n await recordWorkflowRun(\n hostRef,\n workflowRunRow(execution, {\n event,\n workflowId: doc.id,\n workflowName: workflow.name ?? '',\n durationMs: Date.now() - startedAt,\n }),\n )\n continue\n }\n const run = runWorkflow(\n workflow as HostWorkflow,\n functions,\n variables,\n { event, ...payload },\n { workflows: workflowMap },\n )\n const durationMs = Date.now() - startedAt\n executed += 1\n // `=== false` (not `!run.ok`): the union fails to narrow under the\n // stricter build tsconfig otherwise (same quirk as runWorkflow).\n const action =\n run.ok === false\n ? `Workflow failed on ${event}: ${run.error}`.slice(0, 300)\n : `Workflow ran on ${event}`\n /*\n * The run-history shape, not just `status` + `durationMs` (AGL-2222).\n *\n * `actionRunResult()` recognises a run by `result`, falling back to the\n * prose prefix `Action ran on`. This writer's prose begins\n * `Workflow ran on`, so every workflow execution was filtered out of the\n * very table AGL-2171 built — the Runs dialog on the Workflows tab read\n * \"No runs yet\" however many times a workflow had run. The `durationMs`\n * caption in that table could never render either, because the only rows\n * carrying a duration were the ones being dropped.\n *\n * `status` stays: the dashboard activity feed renders it, and removing\n * it would trade one silent gap for another.\n */\n await recordWorkflowRun(hostRef, {\n action,\n result: run.ok === false ? 'failed' : 'succeeded',\n trigger: event,\n ...runSummaryFields(\n run.ok === false ? String(run.error).slice(0, 300) : 'Ran',\n ),\n status: run.ok === false ? 'error' : 'ok',\n durationMs,\n target: { type: 'workflow', id: doc.id, name: workflow.name ?? '' },\n })\n }\n // Filtered-out workflows do not bill — only executed runs count.\n await recordRuns({ ...meter, count: executed })\n } catch (error) {\n console.error('runEventWorkflows failed', hostId, event, error)\n }\n return alerts\n}\n\n/**\n * Continues a WORKFLOW's enrollment from where its wait ended — the\n * workflow twin of the action resume, and asked the same questions.\n *\n * - The kill switch is the workflow document: deleted, and the people inside\n * it stop, exactly as they do when an action is deleted or switched off.\n * - The plan is re-asked: a wait is an Actions step, so a workspace whose\n * plan has lost the actions builder stops the flow rather than finishing\n * it on a plan that no longer carries it.\n * - The snapshot runs, not the workflow's current steps, and the results the\n * function calls bound before the wait ride in its payload.\n * - Counted on the workflow meter, never refused by it: the person is already\n * inside, and the gate belongs at enrollment.\n */\nexport async function resumeWorkflowEnrollment(\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n options: { timedOut?: boolean; nowMs: number },\n): Promise<'ran' | 'waiting' | 'exited' | 'deferred' | 'stopped'> {\n const { nowMs } = options\n const hostId = enrollment.hostId\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const doc = await hostRef\n .collection('workflows')\n .doc(enrollment.actionId)\n .get()\n .catch(() => null)\n if (!doc?.exists || doc.get('deletedAt')) {\n return await stopFlowEnrollment(\n enrollment,\n ref,\n 'the workflow was deleted',\n )\n }\n const workflow = doc.data() as AutomationWorkflow\n const owner = await getOrgForHost(hostId).catch(() => null)\n if (!checkEntitlement(owner?.org as any, 'actions')) {\n return await stopFlowEnrollment(\n enrollment,\n ref,\n 'this site’s plan no longer includes automations',\n )\n }\n const env = automationRunEnv({ hostId, hostRef, owner, depth: 0 })\n const payload: HostEventPayload = {\n ...((enrollment.payload ?? {}) as HostEventPayload),\n // The timeout BRANCH, as on an action's resume: the step after a\n // `waitForEvent` reads it to tell the clock from the event.\n ...(options.timedOut ? { [FLOW_TIMED_OUT_FIELD]: true } : {}),\n }\n const startedAt = Date.now()\n const execution = await executeWorkflow(\n env,\n { kind: 'workflow', id: doc.id, name: workflow.name ?? '' },\n workflow,\n enrollment.event,\n payload,\n {\n startIndex: enrollment.nextStepIndex,\n steps: enrollment.steps,\n enrollmentRef: ref,\n },\n )\n if (execution.ending === 'deferred') {\n await deferFlowEnrollment(ref, nowMs + FLOW_CLAIM_RETRY_MS, nowMs)\n return execution.ending\n }\n if (execution.ending !== 'waiting') await endFlowEnrollment(ref)\n await recordWorkflowRun(\n hostRef,\n workflowRunRow(execution, {\n event: enrollment.event,\n workflowId: doc.id,\n workflowName: workflow.name ?? '',\n durationMs: Date.now() - startedAt,\n }),\n )\n await recordRuns({\n firestore,\n hostRef,\n orgId: owner?.orgId,\n counter: 'workflowRuns',\n month: runMonthKey(nowMs),\n count: 1,\n })\n return execution.ending\n}\n\nexport default runEventWorkflows\n"],"names":["checkEntitlement","evaluateExpression","resolveOrgEntitlements","activitySearchTokens","firebaseAdmin","getOrgForHost","FieldValue","runSummaryFields","ACTION_MAX_EVENT_DEPTH","runWorkflow","eventRunSuspension","recordRuns","runMonthKey","runsUsedThisMonth","deferFlowEnrollment","endFlowEnrollment","automationRunEnv","executeWorkflow","FLOW_CLAIM_RETRY_MS","stopFlowEnrollment","workflowHasActionSteps","runTriggeredByFields","FLOW_TIMED_OUT_FIELD","MAX_TRIGGERED_WORKFLOWS","recordWorkflowRun","hostRef","row","collection","add","actorId","actorEmail","searchTokens","target","createdAt","serverTimestamp","catch","undefined","workflowRunRow","execution","input","failed","errors","length","action","event","join","slice","ending","result","trigger","outcomes","status","durationMs","type","id","workflowId","name","workflowName","runEventWorkflows","hostId","payload","depth","alerts","firestore","app","doc","triggered","where","limit","get","workflows","docs","filter","allWorkflowDocs","workflowMap","data","deletedAt","withId","$id","owner","meter","orgId","counter","month","org","workflowRunsPerMonth","used","functionDocs","variableDocs","Promise","all","functions","definition","variables","variable","executed","workflow","trim","scope","startedAt","Date","now","context","env","loadWorkflowContext","kind","run","ok","error","String","count","console","resumeWorkflowEnrollment","enrollment","ref","options","nowMs","actionId","exists","timedOut","startIndex","nextStepIndex","steps","enrollmentRef"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/run-event-workflows.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n evaluateExpression,\n type HostEventType,\n type HostFunction,\n type HostVariable,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport { activitySearchTokens } from '@aglyn/aglyn/app-utils/activity-search'\nimport { triggerFilterProblem } from '@aglyn/aglyn/app-utils/site-interactions'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { runSummaryFields } from '../model/run-history'\nimport { ACTION_MAX_EVENT_DEPTH, type HostWorkflow, runWorkflow } from '../model/workflows'\nimport { eventRunSuspension } from './site-suspension'\nimport {\n recordRuns,\n type RunMeterScope,\n runMonthKey,\n runsUsedThisMonth,\n} from './run-meter'\nimport {\n deferFlowEnrollment,\n endFlowEnrollment,\n type FlowEnrollment,\n} from './flow-enrollments'\nimport {\n automationRunEnv,\n executeWorkflow,\n FLOW_CLAIM_RETRY_MS,\n SKIP_LOG_EXCLUDED_EVENTS,\n stopFlowEnrollment,\n type WorkflowContext,\n type WorkflowExecution,\n} from './run-event-actions'\nimport {\n type AutomationWorkflow,\n workflowHasActionSteps,\n} from './workflow-steps'\nimport { runTriggeredByFields } from './run-trigger-actor'\nimport { triggeredDocsForEvent } from './triggered-docs'\nimport { FLOW_TIMED_OUT_FIELD, type HostActionAlert } from '../model/host-actions'\n\n/**\n * Every live workflow on the event runs, in document-id order, up to this\n * many (AGL-3458) — the hundred the workflow map below already reads, so a\n * workflow the map can see is one its event can start. See\n * `triggered-docs.ts` for why the dispatch pages rather than truncates.\n */\nconst MAX_TRIGGERED_WORKFLOWS = 100\n\n/**\n * One workflow run's row in the site's activity feed, which is the run\n * history the Workflows tab reads.\n *\n * The one place a workflow's run is written, whichever door started it.\n */\nasync function recordWorkflowRun(\n hostRef: FirebaseFirestore.DocumentReference,\n row: Record<string, unknown>,\n): Promise<void> {\n await hostRef\n .collection('activity')\n .add({\n actorId: null,\n actorEmail: null,\n // Who set this run off (AGL-3376).\n ...runTriggeredByFields(),\n ...row,\n // The site log's search finds a run by its workflow's name (AGL-3321).\n searchTokens: activitySearchTokens({\n target: row['target'] as { name?: unknown } | undefined,\n }),\n createdAt: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n}\n\n/**\n * The history row for a run of a workflow with Actions steps, in the Actions\n * run's phrasing (AGL-2171) — what each step did, joined — so the run table\n * shows every step's outcome whichever engine the step came from. `status`\n * and `durationMs` ride along as they do on every workflow run, for the\n * dashboard feed that renders them.\n */\nexport function workflowRunRow(\n execution: Pick<WorkflowExecution, 'ending' | 'errors' | 'outcomes'>,\n input: {\n event: string\n workflowId: string\n workflowName: string\n durationMs: number\n },\n): Record<string, unknown> {\n const failed = execution.errors.length > 0\n const action = failed\n ? `Workflow ran on ${input.event} with errors: ${execution.errors.join('; ')}`.slice(\n 0,\n 300,\n )\n : execution.ending === 'waiting'\n ? `Workflow is waiting, on ${input.event}`\n : `Workflow ran on ${input.event}`\n return {\n action,\n result: failed ? 'failed' : 'succeeded',\n trigger: input.event,\n // The summary and its search tokens, written together (AGL-3321).\n ...runSummaryFields(\n (execution.outcomes.length\n ? execution.outcomes.join(' · ')\n : 'Ran'\n ).slice(0, 300),\n ),\n status: failed ? 'error' : 'ok',\n durationMs: input.durationMs,\n target: {\n type: 'workflow',\n id: input.workflowId,\n name: input.workflowName,\n },\n }\n}\n\n/**\n * Event-triggered workflow runner (AGL-128): loads workflows whose\n * `trigger.event` matches, evaluates optional trigger filters over the\n * event payload, runs each, and logs runs to the host activity feed.\n * Fire-and-forget from the tenant's event sites (form submit, pageview\n * collector, membership APIs) — it never throws, and a failure must not\n * break the request that emitted the event.\n *\n * A workflow of function calls runs through the pure `runWorkflow`\n * evaluator exactly as it always has. A workflow with Actions steps runs\n * through `executeWorkflow`, which performs them with the Actions executors;\n * the site alerts its steps raise are what this resolves.\n *\n * ONE RUN, METERED ONCE: either kind counts one run on the workflow meter,\n * `workflowRunsPerMonth`, however many steps it has and whatever they are.\n * Its Actions steps take the Actions tier gates step by step, and none of\n * them counts an action run.\n *\n * `depth` is the chain guard actions run under (`ACTION_MAX_EVENT_DEPTH`):\n * an event a workflow's own step raised — a stage it set — is heard one\n * level deeper, so a workflow that sets the stage it listens for stops.\n */\nexport async function runEventWorkflows(\n hostId: string,\n event: HostEventType,\n payload: HostEventPayload = {},\n depth = 0,\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n if (depth > ACTION_MAX_EVENT_DEPTH) return alerts\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const workflows = await triggeredDocsForEvent(hostRef.collection('workflows'), event, {\n keep: (doc) => !doc.get('deletedAt'),\n max: MAX_TRIGGERED_WORKFLOWS,\n maxReads: MAX_TRIGGERED_WORKFLOWS * 4,\n })\n if (!workflows.length) return alerts\n // Full workflow map for nested function→workflow calls (AGL-129).\n const allWorkflowDocs = await hostRef\n .collection('workflows')\n .limit(100)\n .get()\n // Double-keyed by doc id AND name (AGL-261): id references are\n // rename-safe; legacy name references keep resolving.\n // Each carries its id, so a workflow a step names by its legacy name\n // still runs as the automation it is (see `WorkflowContext`).\n const workflowMap: WorkflowContext['workflows'] = {}\n for (const doc of allWorkflowDocs.docs) {\n const data = doc.data() as any\n if (data.deletedAt) continue\n const withId = { ...data, $id: doc.id }\n workflowMap[doc.id] = withId\n if (data?.name) workflowMap[data.name] = withId\n }\n\n // Monthly run cap by the owning org's plan (AGL-165).\n // Run caps ride the owning org's doc (AGL-238) — read through\n // `getOrgForHost` below. The host document itself is not consulted: the\n // cap, the counter and the workflow definitions all live elsewhere, so a\n // read of it here would be billed on every host event and used for\n // nothing.\n const owner = await getOrgForHost(hostId)\n // The band is the WORKSPACE's (AGL-3472): every site's runs count\n // against it, read off the org's counter — see `run-meter.ts`.\n const meter: RunMeterScope = {\n firestore,\n hostRef,\n orgId: owner?.orgId,\n counter: 'workflowRuns',\n month: runMonthKey(),\n }\n // A suspended site runs nothing (AGL-3356): see `eventRunSuspension`.\n // Asked before the cap so a locked site's events bill no runs.\n if (await eventRunSuspension(hostRef, owner?.org)) return alerts\n {\n const org = owner?.org\n // Plan-less orgs resolve as free (AGL-247) — the cap always runs.\n const limit = resolveOrgEntitlements(\n org as any,\n ).workflowRunsPerMonth\n const used = await runsUsedThisMonth(meter)\n if (used + workflows.length > limit) return alerts\n }\n\n const [functionDocs, variableDocs] = await Promise.all([\n hostRef.collection('functions').limit(100).get(),\n hostRef.collection('variables').limit(100).get(),\n ])\n const functions: Record<string, HostFunction> = {}\n for (const doc of functionDocs.docs) {\n const definition = doc.data() as any\n if (definition.deletedAt) continue\n functions[doc.id] = definition\n if (definition.name) functions[definition.name] = definition\n }\n const variables: Record<string, HostVariable> = {}\n for (const doc of variableDocs.docs) {\n const variable = doc.data() as any\n if (!variable.deletedAt && variable.name) {\n variables[variable.name] = variable\n }\n }\n\n let executed = 0\n for (const doc of workflows) {\n const workflow = doc.data() as AutomationWorkflow\n const filter = workflow.trigger?.filter?.trim()\n if (filter) {\n /*\n * A filter no event can ever satisfy — a comparison the evaluator\n * does not have, text it cannot read — answers \"why didn't it run?\"\n * in the run history, on every event it would have run on, rather\n * than leaving the workflow silent. The editor refuses to save one;\n * this is the one stored before it did, or written around it.\n */\n const unrunnable = triggerFilterProblem(filter, { remedy: 'action' })\n if (unrunnable) {\n if (!SKIP_LOG_EXCLUDED_EVENTS.has(event)) {\n await recordWorkflowRun(hostRef, {\n action: `Workflow skipped on ${event}`,\n result: 'skipped',\n trigger: event,\n ...runSummaryFields(unrunnable.slice(0, 300)),\n target: { type: 'workflow', id: doc.id, name: workflow.name ?? '' },\n })\n }\n continue\n }\n try {\n const scope: HostEventPayload = { event, ...payload }\n if (!evaluateExpression(filter, scope)) continue\n } catch {\n continue // A field the filter names is not on this event.\n }\n }\n const startedAt = Date.now()\n if (workflowHasActionSteps(workflow)) {\n /*\n * A WORKFLOW WITH ACTIONS STEPS is performed, not evaluated. The\n * maps above are this run's working set, so the executor reads them\n * rather than a second copy; the plan gates come off the org\n * document the cap already read.\n */\n const context: WorkflowContext = {\n functions,\n variables,\n workflows: workflowMap,\n }\n const env = automationRunEnv({\n hostId,\n hostRef,\n owner,\n depth,\n alerts,\n loadWorkflowContext: async () => context,\n })\n const execution = await executeWorkflow(\n env,\n { kind: 'workflow', id: doc.id, name: workflow.name ?? '' },\n workflow,\n event,\n payload,\n )\n executed += 1\n await recordWorkflowRun(\n hostRef,\n workflowRunRow(execution, {\n event,\n workflowId: doc.id,\n workflowName: workflow.name ?? '',\n durationMs: Date.now() - startedAt,\n }),\n )\n continue\n }\n const run = runWorkflow(\n workflow as HostWorkflow,\n functions,\n variables,\n { event, ...payload },\n { workflows: workflowMap },\n )\n const durationMs = Date.now() - startedAt\n executed += 1\n // `=== false` (not `!run.ok`): the union fails to narrow under the\n // stricter build tsconfig otherwise (same quirk as runWorkflow).\n const action =\n run.ok === false\n ? `Workflow failed on ${event}: ${run.error}`.slice(0, 300)\n : `Workflow ran on ${event}`\n /*\n * The run-history shape, not just `status` + `durationMs` (AGL-2222).\n *\n * `actionRunResult()` recognises a run by `result`, falling back to the\n * prose prefix `Action ran on`. This writer's prose begins\n * `Workflow ran on`, so every workflow execution was filtered out of the\n * very table AGL-2171 built — the Runs dialog on the Workflows tab read\n * \"No runs yet\" however many times a workflow had run. The `durationMs`\n * caption in that table could never render either, because the only rows\n * carrying a duration were the ones being dropped.\n *\n * `status` stays: the dashboard activity feed renders it, and removing\n * it would trade one silent gap for another.\n */\n await recordWorkflowRun(hostRef, {\n action,\n result: run.ok === false ? 'failed' : 'succeeded',\n trigger: event,\n ...runSummaryFields(\n run.ok === false ? String(run.error).slice(0, 300) : 'Ran',\n ),\n status: run.ok === false ? 'error' : 'ok',\n durationMs,\n target: { type: 'workflow', id: doc.id, name: workflow.name ?? '' },\n })\n }\n // Filtered-out workflows do not bill — only executed runs count.\n await recordRuns({ ...meter, count: executed })\n } catch (error) {\n console.error('runEventWorkflows failed', hostId, event, error)\n }\n return alerts\n}\n\n/**\n * Continues a WORKFLOW's enrollment from where its wait ended — the\n * workflow twin of the action resume, and asked the same questions.\n *\n * - The kill switch is the workflow document: deleted, and the people inside\n * it stop, exactly as they do when an action is deleted or switched off.\n * - The plan is re-asked: a wait is an Actions step, so a workspace whose\n * plan has lost the actions builder stops the flow rather than finishing\n * it on a plan that no longer carries it.\n * - The snapshot runs, not the workflow's current steps, and the results the\n * function calls bound before the wait ride in its payload.\n * - Counted on the workflow meter, never refused by it: the person is already\n * inside, and the gate belongs at enrollment.\n */\nexport async function resumeWorkflowEnrollment(\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n options: { timedOut?: boolean; nowMs: number },\n): Promise<'ran' | 'waiting' | 'exited' | 'deferred' | 'stopped'> {\n const { nowMs } = options\n const hostId = enrollment.hostId\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const doc = await hostRef\n .collection('workflows')\n .doc(enrollment.actionId)\n .get()\n .catch(() => null)\n if (!doc?.exists || doc.get('deletedAt')) {\n return await stopFlowEnrollment(\n enrollment,\n ref,\n 'the workflow was deleted',\n )\n }\n const workflow = doc.data() as AutomationWorkflow\n const owner = await getOrgForHost(hostId).catch(() => null)\n if (!checkEntitlement(owner?.org as any, 'actions')) {\n return await stopFlowEnrollment(\n enrollment,\n ref,\n 'this site’s plan no longer includes automations',\n )\n }\n const env = automationRunEnv({ hostId, hostRef, owner, depth: 0 })\n const payload: HostEventPayload = {\n ...((enrollment.payload ?? {}) as HostEventPayload),\n // The timeout BRANCH, as on an action's resume: the step after a\n // `waitForEvent` reads it to tell the clock from the event.\n ...(options.timedOut ? { [FLOW_TIMED_OUT_FIELD]: true } : {}),\n }\n const startedAt = Date.now()\n const execution = await executeWorkflow(\n env,\n { kind: 'workflow', id: doc.id, name: workflow.name ?? '' },\n workflow,\n enrollment.event,\n payload,\n {\n startIndex: enrollment.nextStepIndex,\n steps: enrollment.steps,\n enrollmentRef: ref,\n },\n )\n if (execution.ending === 'deferred') {\n await deferFlowEnrollment(ref, nowMs + FLOW_CLAIM_RETRY_MS, nowMs)\n return execution.ending\n }\n if (execution.ending !== 'waiting') await endFlowEnrollment(ref)\n await recordWorkflowRun(\n hostRef,\n workflowRunRow(execution, {\n event: enrollment.event,\n workflowId: doc.id,\n workflowName: workflow.name ?? '',\n durationMs: Date.now() - startedAt,\n }),\n )\n await recordRuns({\n firestore,\n hostRef,\n orgId: owner?.orgId,\n counter: 'workflowRuns',\n month: runMonthKey(nowMs),\n count: 1,\n })\n return execution.ending\n}\n\nexport default runEventWorkflows\n"],"names":["checkEntitlement","evaluateExpression","resolveOrgEntitlements","activitySearchTokens","triggerFilterProblem","firebaseAdmin","getOrgForHost","FieldValue","runSummaryFields","ACTION_MAX_EVENT_DEPTH","runWorkflow","eventRunSuspension","recordRuns","runMonthKey","runsUsedThisMonth","deferFlowEnrollment","endFlowEnrollment","automationRunEnv","executeWorkflow","FLOW_CLAIM_RETRY_MS","SKIP_LOG_EXCLUDED_EVENTS","stopFlowEnrollment","workflowHasActionSteps","runTriggeredByFields","triggeredDocsForEvent","FLOW_TIMED_OUT_FIELD","MAX_TRIGGERED_WORKFLOWS","recordWorkflowRun","hostRef","row","collection","add","actorId","actorEmail","searchTokens","target","createdAt","serverTimestamp","catch","undefined","workflowRunRow","execution","input","failed","errors","length","action","event","join","slice","ending","result","trigger","outcomes","status","durationMs","type","id","workflowId","name","workflowName","runEventWorkflows","hostId","payload","depth","alerts","firestore","app","doc","workflows","keep","get","max","maxReads","allWorkflowDocs","limit","workflowMap","docs","data","deletedAt","withId","$id","owner","meter","orgId","counter","month","org","workflowRunsPerMonth","used","functionDocs","variableDocs","Promise","all","functions","definition","variables","variable","executed","workflow","filter","trim","unrunnable","remedy","has","scope","startedAt","Date","now","context","env","loadWorkflowContext","kind","run","ok","error","String","count","console","resumeWorkflowEnrollment","enrollment","ref","options","nowMs","actionId","exists","timedOut","startIndex","nextStepIndex","steps","enrollmentRef"],"mappings":"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"}
|
|
@@ -72,7 +72,9 @@ async function orgIdForHost(hostId) {
|
|
|
72
72
|
const orgId = await orgIdForHost(hostId);
|
|
73
73
|
if (!orgId) return null;
|
|
74
74
|
const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection(ORG_AUTOMATIONS_COLLECTION).where('trigger.event', '==', event).where('enabled', '==', true).where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)).limit(MAX_TRIGGERED_ORG_AUTOMATIONS).get();
|
|
75
|
-
|
|
75
|
+
// In document-id order (AGL-3458), so which runs first does not depend
|
|
76
|
+
// on how the query happened to answer.
|
|
77
|
+
const docs = snapshot.docs.filter((doc)=>orgAutomationRunsOnHost(doc.data(), hostId)).sort((a, b)=>a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
76
78
|
return docs.length ? {
|
|
77
79
|
orgId,
|
|
78
80
|
docs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/run-org-automations.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n firebaseAdmin,\n getOrgForHost,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin'\nimport type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners'\nimport {\n isOrgAutomationTriggerEvent,\n MAX_TRIGGERED_ORG_AUTOMATIONS,\n ORG_AUTOMATIONS_COLLECTION,\n type OrgAutomation,\n orgAutomationRunsOnHost,\n orgAutomationStopReason,\n} from '../model/org-automations'\nimport {\n deferFlowEnrollment,\n endFlowEnrollment,\n type FlowEnrollment,\n} from './flow-enrollments'\nimport {\n type ActionRunEnv,\n executeAction,\n FLOW_CLAIM_RETRY_MS,\n makeWorkflowContextLoader,\n stopFlowEnrollment,\n} from './run-event-actions'\nimport { FLOW_TIMED_OUT_FIELD, type HostActionStep } from '../model/host-actions'\nimport { recordRuns, runMonthKey } from './run-meter'\n\n/**\n * THE ORGANIZATION'S AUTOMATIONS, as the engine meets them (AGL-3302).\n *\n * Two doors lead here. An event arrives and the site's own actions are\n * joined by the org automations placed on the site — `runEventActions` asks\n * {@link findOrgAutomationsForEvent} and runs what it answers beside them. A\n * person waiting inside an org automation comes due, or the event they were\n * waiting for arrives, and {@link resumeOrgAutomationEnrollment} continues\n * them against the organization's document.\n *\n * Both run on the event's SITE. The run environment is the site's, so the\n * sending identity, the suppression lists, the consent group, the email meter\n * and the run allowance are all the site's — an automation placed on five\n * sites is five sites running it for themselves.\n */\n\n/** The org automations one event runs on one site, and whose they are. */\nexport interface PlacedOrgAutomations {\n orgId: string\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n}\n\n/**\n * How long a site's organization is remembered between events.\n *\n * The `hostIndex` read is the price of asking the organization at all, and it\n * repeats on every server event of a busy site and again for each event a\n * run's own steps raise. Remembered briefly rather than forever: a site that\n * changes hands is picked up within the window, and the engine refuses to run\n * a remembered organization's automations for a site whose current owner —\n * read fresh by the entitlement gate before anything runs — is another.\n */\nconst ORG_LOOKUP_TTL_MS = 60_000\n\n/** Entries kept before the memo starts over; far past one instance's working set. */\nconst ORG_LOOKUP_MAX_ENTRIES = 1_000\n\nconst orgLookups = new Map<string, { orgId: string | null; atMs: number }>()\n\n/** Test seam: forget every remembered organization. */\nexport function resetOrgAutomationLookupsForTests(): void {\n orgLookups.clear()\n}\n\nasync function orgIdForHost(hostId: string): Promise<string | null> {\n const nowMs = Date.now()\n const known = orgLookups.get(hostId)\n if (known && nowMs - known.atMs < ORG_LOOKUP_TTL_MS) return known.orgId\n const orgId = await resolveOrgIdForHost(hostId)\n if (orgLookups.size >= ORG_LOOKUP_MAX_ENTRIES) orgLookups.clear()\n orgLookups.set(hostId, { orgId, atMs: nowMs })\n return orgId\n}\n\n/**\n * The org automations this event runs on this site, or null when there are\n * none — including when the event is not one an org automation can start on,\n * which is answered before any read.\n *\n * One query: the event, switched on, and placed on this site — by the org\n * token or the site's own — capped at {@link MAX_TRIGGERED_ORG_AUTOMATIONS}.\n * `enabled` is in the query rather than filtered after it so a switched-off\n * or deleted automation (deletion switches it off too) can never take one of\n * the capped places; a PAUSE is per site and cannot be asked of an array in\n * the same query, so a paused automation is dropped here, after it.\n *\n * Served by the composite index on `automations` — `visibleTo` CONTAINS,\n * `trigger.event`, `enabled` — in `cloud/firebase-firestore.indexes.json`.\n *\n * Never throws: an organization that cannot be read runs no org automation,\n * and the site's own actions run exactly as they would have.\n */\nexport async function findOrgAutomationsForEvent(\n hostId: string,\n event: string,\n): Promise<PlacedOrgAutomations | null> {\n if (!isOrgAutomationTriggerEvent(event)) return null\n try {\n const orgId = await orgIdForHost(hostId)\n if (!orgId) return null\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_AUTOMATIONS_COLLECTION)\n .where('trigger.event', '==', event)\n .where('enabled', '==', true)\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n .limit(MAX_TRIGGERED_ORG_AUTOMATIONS)\n .get()\n const docs = snapshot.docs.filter((doc) =>\n orgAutomationRunsOnHost(doc.data() as OrgAutomation, hostId),\n )\n return docs.length ? { orgId, docs } : null\n } catch (error) {\n console.error('[org automations] lookup failed', hostId, event, error)\n return null\n }\n}\n\n/**\n * Continues one person waiting inside an org automation, on the site they\n * were enrolled on — the org twin of the site action's resume, asking the\n * same questions of the organization's document instead of the site's.\n *\n * - The kill switch is `orgs/{orgId}/automations/{id}`: deleted, switched\n * off, taken off this site, or paused on this site, and the person stops.\n * - The site must still belong to that organization, and its plan must\n * still carry the actions builder.\n * - The snapshot runs, never the automation's current steps.\n * - Counted on this site's action-run meter and the workspace's, never\n * refused by them: the person is already inside, and the gate belongs at\n * enrollment.\n */\nexport async function resumeOrgAutomationEnrollment(\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n options: { timedOut?: boolean; nowMs: number },\n): Promise<'ran' | 'waiting' | 'exited' | 'deferred' | 'stopped'> {\n const { nowMs } = options\n const hostId = enrollment.hostId\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const stop = (reason: string) => stopFlowEnrollment(enrollment, ref, reason)\n\n const orgId = String(enrollment.orgId ?? '')\n const doc = orgId\n ? await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_AUTOMATIONS_COLLECTION)\n .doc(enrollment.actionId)\n .get()\n .catch(() => null)\n : null\n const automation = doc?.exists ? (doc.data() as OrgAutomation) : null\n if (!automation) return await stop('the org automation was deleted')\n const refusal = orgAutomationStopReason(automation, hostId)\n if (refusal) return await stop(refusal)\n\n const owner = await getOrgForHost(hostId).catch(() => null)\n if (!owner || owner.orgId !== orgId) {\n return await stop(\n 'this site is no longer part of the organization that runs the org automation',\n )\n }\n if (!checkEntitlement(owner.org as any, 'actions')) {\n return await stop('this site’s plan no longer includes automations')\n }\n\n const env: ActionRunEnv = {\n hostId,\n hostRef,\n alerts: [],\n // Admitted by the `actions` gate above.\n actionsAllowed: true,\n webhooksAllowed: checkEntitlement(owner.org as any, 'webhooks'),\n depth: 0,\n org: owner.org ?? null,\n orgId: owner.orgId,\n loadWorkflowContext: makeWorkflowContextLoader(hostRef),\n }\n const payload: HostEventPayload = {\n ...((enrollment.payload ?? {}) as HostEventPayload),\n // The timeout BRANCH, as on a site action's resume.\n ...(options.timedOut ? { [FLOW_TIMED_OUT_FIELD]: true } : {}),\n }\n const ending = await executeAction(\n env,\n enrollment.actionId,\n automation,\n enrollment.event,\n payload,\n {\n startIndex: enrollment.nextStepIndex,\n steps: enrollment.steps as HostActionStep[],\n enrollmentRef: ref,\n orgAutomation: { orgId },\n },\n )\n if (ending === 'deferred') {\n await deferFlowEnrollment(ref, nowMs + FLOW_CLAIM_RETRY_MS, nowMs)\n return ending\n }\n if (ending !== 'waiting') await endFlowEnrollment(ref)\n await recordRuns({\n firestore,\n hostRef,\n orgId: owner.orgId,\n counter: 'actionRuns',\n month: runMonthKey(nowMs),\n count: 1,\n })\n return ending\n}\n"],"names":["checkEntitlement","scopeTokensForHost","firebaseAdmin","getOrgForHost","resolveOrgIdForHost","isOrgAutomationTriggerEvent","MAX_TRIGGERED_ORG_AUTOMATIONS","ORG_AUTOMATIONS_COLLECTION","orgAutomationRunsOnHost","orgAutomationStopReason","deferFlowEnrollment","endFlowEnrollment","executeAction","FLOW_CLAIM_RETRY_MS","makeWorkflowContextLoader","stopFlowEnrollment","FLOW_TIMED_OUT_FIELD","recordRuns","runMonthKey","ORG_LOOKUP_TTL_MS","ORG_LOOKUP_MAX_ENTRIES","orgLookups","Map","resetOrgAutomationLookupsForTests","clear","orgIdForHost","hostId","nowMs","Date","now","known","get","atMs","orgId","size","set","findOrgAutomationsForEvent","event","snapshot","app","firestore","collection","doc","where","limit","docs","filter","data","length","error","console","resumeOrgAutomationEnrollment","enrollment","ref","options","owner","hostRef","stop","reason","String","actionId","catch","automation","exists","refusal","org","env","alerts","actionsAllowed","webhooksAllowed","depth","loadWorkflowContext","payload","timedOut","ending","startIndex","nextStepIndex","steps","enrollmentRef","orgAutomation","counter","month","count"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/run-org-automations.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n firebaseAdmin,\n getOrgForHost,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin'\nimport type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners'\nimport {\n isOrgAutomationTriggerEvent,\n MAX_TRIGGERED_ORG_AUTOMATIONS,\n ORG_AUTOMATIONS_COLLECTION,\n type OrgAutomation,\n orgAutomationRunsOnHost,\n orgAutomationStopReason,\n} from '../model/org-automations'\nimport {\n deferFlowEnrollment,\n endFlowEnrollment,\n type FlowEnrollment,\n} from './flow-enrollments'\nimport {\n type ActionRunEnv,\n executeAction,\n FLOW_CLAIM_RETRY_MS,\n makeWorkflowContextLoader,\n stopFlowEnrollment,\n} from './run-event-actions'\nimport { FLOW_TIMED_OUT_FIELD, type HostActionStep } from '../model/host-actions'\nimport { recordRuns, runMonthKey } from './run-meter'\n\n/**\n * THE ORGANIZATION'S AUTOMATIONS, as the engine meets them (AGL-3302).\n *\n * Two doors lead here. An event arrives and the site's own actions are\n * joined by the org automations placed on the site — `runEventActions` asks\n * {@link findOrgAutomationsForEvent} and runs what it answers beside them. A\n * person waiting inside an org automation comes due, or the event they were\n * waiting for arrives, and {@link resumeOrgAutomationEnrollment} continues\n * them against the organization's document.\n *\n * Both run on the event's SITE. The run environment is the site's, so the\n * sending identity, the suppression lists, the consent group, the email meter\n * and the run allowance are all the site's — an automation placed on five\n * sites is five sites running it for themselves.\n */\n\n/** The org automations one event runs on one site, and whose they are. */\nexport interface PlacedOrgAutomations {\n orgId: string\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n}\n\n/**\n * How long a site's organization is remembered between events.\n *\n * The `hostIndex` read is the price of asking the organization at all, and it\n * repeats on every server event of a busy site and again for each event a\n * run's own steps raise. Remembered briefly rather than forever: a site that\n * changes hands is picked up within the window, and the engine refuses to run\n * a remembered organization's automations for a site whose current owner —\n * read fresh by the entitlement gate before anything runs — is another.\n */\nconst ORG_LOOKUP_TTL_MS = 60_000\n\n/** Entries kept before the memo starts over; far past one instance's working set. */\nconst ORG_LOOKUP_MAX_ENTRIES = 1_000\n\nconst orgLookups = new Map<string, { orgId: string | null; atMs: number }>()\n\n/** Test seam: forget every remembered organization. */\nexport function resetOrgAutomationLookupsForTests(): void {\n orgLookups.clear()\n}\n\nasync function orgIdForHost(hostId: string): Promise<string | null> {\n const nowMs = Date.now()\n const known = orgLookups.get(hostId)\n if (known && nowMs - known.atMs < ORG_LOOKUP_TTL_MS) return known.orgId\n const orgId = await resolveOrgIdForHost(hostId)\n if (orgLookups.size >= ORG_LOOKUP_MAX_ENTRIES) orgLookups.clear()\n orgLookups.set(hostId, { orgId, atMs: nowMs })\n return orgId\n}\n\n/**\n * The org automations this event runs on this site, or null when there are\n * none — including when the event is not one an org automation can start on,\n * which is answered before any read.\n *\n * One query: the event, switched on, and placed on this site — by the org\n * token or the site's own — capped at {@link MAX_TRIGGERED_ORG_AUTOMATIONS}.\n * `enabled` is in the query rather than filtered after it so a switched-off\n * or deleted automation (deletion switches it off too) can never take one of\n * the capped places; a PAUSE is per site and cannot be asked of an array in\n * the same query, so a paused automation is dropped here, after it.\n *\n * Served by the composite index on `automations` — `visibleTo` CONTAINS,\n * `trigger.event`, `enabled` — in `cloud/firebase-firestore.indexes.json`.\n *\n * Never throws: an organization that cannot be read runs no org automation,\n * and the site's own actions run exactly as they would have.\n */\nexport async function findOrgAutomationsForEvent(\n hostId: string,\n event: string,\n): Promise<PlacedOrgAutomations | null> {\n if (!isOrgAutomationTriggerEvent(event)) return null\n try {\n const orgId = await orgIdForHost(hostId)\n if (!orgId) return null\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_AUTOMATIONS_COLLECTION)\n .where('trigger.event', '==', event)\n .where('enabled', '==', true)\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n .limit(MAX_TRIGGERED_ORG_AUTOMATIONS)\n .get()\n // In document-id order (AGL-3458), so which runs first does not depend\n // on how the query happened to answer.\n const docs = snapshot.docs\n .filter((doc) => orgAutomationRunsOnHost(doc.data() as OrgAutomation, hostId))\n .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))\n return docs.length ? { orgId, docs } : null\n } catch (error) {\n console.error('[org automations] lookup failed', hostId, event, error)\n return null\n }\n}\n\n/**\n * Continues one person waiting inside an org automation, on the site they\n * were enrolled on — the org twin of the site action's resume, asking the\n * same questions of the organization's document instead of the site's.\n *\n * - The kill switch is `orgs/{orgId}/automations/{id}`: deleted, switched\n * off, taken off this site, or paused on this site, and the person stops.\n * - The site must still belong to that organization, and its plan must\n * still carry the actions builder.\n * - The snapshot runs, never the automation's current steps.\n * - Counted on this site's action-run meter and the workspace's, never\n * refused by them: the person is already inside, and the gate belongs at\n * enrollment.\n */\nexport async function resumeOrgAutomationEnrollment(\n enrollment: FlowEnrollment,\n ref: FirebaseFirestore.DocumentReference,\n options: { timedOut?: boolean; nowMs: number },\n): Promise<'ran' | 'waiting' | 'exited' | 'deferred' | 'stopped'> {\n const { nowMs } = options\n const hostId = enrollment.hostId\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const stop = (reason: string) => stopFlowEnrollment(enrollment, ref, reason)\n\n const orgId = String(enrollment.orgId ?? '')\n const doc = orgId\n ? await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_AUTOMATIONS_COLLECTION)\n .doc(enrollment.actionId)\n .get()\n .catch(() => null)\n : null\n const automation = doc?.exists ? (doc.data() as OrgAutomation) : null\n if (!automation) return await stop('the org automation was deleted')\n const refusal = orgAutomationStopReason(automation, hostId)\n if (refusal) return await stop(refusal)\n\n const owner = await getOrgForHost(hostId).catch(() => null)\n if (!owner || owner.orgId !== orgId) {\n return await stop(\n 'this site is no longer part of the organization that runs the org automation',\n )\n }\n if (!checkEntitlement(owner.org as any, 'actions')) {\n return await stop('this site’s plan no longer includes automations')\n }\n\n const env: ActionRunEnv = {\n hostId,\n hostRef,\n alerts: [],\n // Admitted by the `actions` gate above.\n actionsAllowed: true,\n webhooksAllowed: checkEntitlement(owner.org as any, 'webhooks'),\n depth: 0,\n org: owner.org ?? null,\n orgId: owner.orgId,\n loadWorkflowContext: makeWorkflowContextLoader(hostRef),\n }\n const payload: HostEventPayload = {\n ...((enrollment.payload ?? {}) as HostEventPayload),\n // The timeout BRANCH, as on a site action's resume.\n ...(options.timedOut ? { [FLOW_TIMED_OUT_FIELD]: true } : {}),\n }\n const ending = await executeAction(\n env,\n enrollment.actionId,\n automation,\n enrollment.event,\n payload,\n {\n startIndex: enrollment.nextStepIndex,\n steps: enrollment.steps as HostActionStep[],\n enrollmentRef: ref,\n orgAutomation: { orgId },\n },\n )\n if (ending === 'deferred') {\n await deferFlowEnrollment(ref, nowMs + FLOW_CLAIM_RETRY_MS, nowMs)\n return ending\n }\n if (ending !== 'waiting') await endFlowEnrollment(ref)\n await recordRuns({\n firestore,\n hostRef,\n orgId: owner.orgId,\n counter: 'actionRuns',\n month: runMonthKey(nowMs),\n count: 1,\n })\n return ending\n}\n"],"names":["checkEntitlement","scopeTokensForHost","firebaseAdmin","getOrgForHost","resolveOrgIdForHost","isOrgAutomationTriggerEvent","MAX_TRIGGERED_ORG_AUTOMATIONS","ORG_AUTOMATIONS_COLLECTION","orgAutomationRunsOnHost","orgAutomationStopReason","deferFlowEnrollment","endFlowEnrollment","executeAction","FLOW_CLAIM_RETRY_MS","makeWorkflowContextLoader","stopFlowEnrollment","FLOW_TIMED_OUT_FIELD","recordRuns","runMonthKey","ORG_LOOKUP_TTL_MS","ORG_LOOKUP_MAX_ENTRIES","orgLookups","Map","resetOrgAutomationLookupsForTests","clear","orgIdForHost","hostId","nowMs","Date","now","known","get","atMs","orgId","size","set","findOrgAutomationsForEvent","event","snapshot","app","firestore","collection","doc","where","limit","docs","filter","data","sort","a","b","id","length","error","console","resumeOrgAutomationEnrollment","enrollment","ref","options","owner","hostRef","stop","reason","String","actionId","catch","automation","exists","refusal","org","env","alerts","actionsAllowed","webhooksAllowed","depth","loadWorkflowContext","payload","timedOut","ending","startIndex","nextStepIndex","steps","enrollmentRef","orgAutomation","counter","month","count"],"mappings":"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"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* EVERY LIVE AUTOMATION ON AN EVENT RUNS (AGL-3458).
|
|
19
|
+
*
|
|
20
|
+
* A site with one auto-reply per form holds more automations on
|
|
21
|
+
* `formSubmission` than any fixed first page, and every live one of them has
|
|
22
|
+
* to run. This reads the event's documents in pages, in document-id order,
|
|
23
|
+
* keeps the ones that may run, and hands back every one of them, in that
|
|
24
|
+
* order, up to `max` — so which automations run never depends on how many
|
|
25
|
+
* deleted or switched-off ones sort ahead of them.
|
|
26
|
+
*
|
|
27
|
+
* `deletedAt` and a missing `enabled` cannot be asked of a query — a field's
|
|
28
|
+
* absence matches nothing — so `keep` judges them here, on every page.
|
|
29
|
+
* Equality on `trigger.event` and an order by document id need only the
|
|
30
|
+
* automatic single-field index. The reads are bounded twice: by `max`, and by
|
|
31
|
+
* `maxReads` for a site whose deleted automations on one event have piled up.
|
|
32
|
+
*/
|
|
33
|
+
/** One page of an event's automations. */
|
|
34
|
+
export declare const TRIGGERED_DOCS_PAGE = 100;
|
|
35
|
+
export declare function triggeredDocsForEvent(collection: FirebaseFirestore.CollectionReference, event: string, options: {
|
|
36
|
+
keep: (doc: FirebaseFirestore.QueryDocumentSnapshot) => boolean;
|
|
37
|
+
max: number;
|
|
38
|
+
maxReads: number;
|
|
39
|
+
}): Promise<FirebaseFirestore.QueryDocumentSnapshot[]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* EVERY LIVE AUTOMATION ON AN EVENT RUNS (AGL-3458).
|
|
18
|
+
*
|
|
19
|
+
* A site with one auto-reply per form holds more automations on
|
|
20
|
+
* `formSubmission` than any fixed first page, and every live one of them has
|
|
21
|
+
* to run. This reads the event's documents in pages, in document-id order,
|
|
22
|
+
* keeps the ones that may run, and hands back every one of them, in that
|
|
23
|
+
* order, up to `max` — so which automations run never depends on how many
|
|
24
|
+
* deleted or switched-off ones sort ahead of them.
|
|
25
|
+
*
|
|
26
|
+
* `deletedAt` and a missing `enabled` cannot be asked of a query — a field's
|
|
27
|
+
* absence matches nothing — so `keep` judges them here, on every page.
|
|
28
|
+
* Equality on `trigger.event` and an order by document id need only the
|
|
29
|
+
* automatic single-field index. The reads are bounded twice: by `max`, and by
|
|
30
|
+
* `maxReads` for a site whose deleted automations on one event have piled up.
|
|
31
|
+
*/ /** One page of an event's automations. */ export const TRIGGERED_DOCS_PAGE = 100;
|
|
32
|
+
export async function triggeredDocsForEvent(collection, event, options) {
|
|
33
|
+
const kept = [];
|
|
34
|
+
let cursor = null;
|
|
35
|
+
for(let read = 0; read < options.maxReads;){
|
|
36
|
+
let query = collection.where('trigger.event', '==', event).orderBy('__name__').limit(TRIGGERED_DOCS_PAGE);
|
|
37
|
+
if (cursor) query = query.startAfter(cursor);
|
|
38
|
+
const { docs } = await query.get();
|
|
39
|
+
read += docs.length;
|
|
40
|
+
for (const doc of docs)if (options.keep(doc)) kept.push(doc);
|
|
41
|
+
if (docs.length < TRIGGERED_DOCS_PAGE || kept.length >= options.max) break;
|
|
42
|
+
cursor = docs[docs.length - 1];
|
|
43
|
+
}
|
|
44
|
+
return kept.slice(0, options.max);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=triggered-docs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/triggered-docs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * EVERY LIVE AUTOMATION ON AN EVENT RUNS (AGL-3458).\n *\n * A site with one auto-reply per form holds more automations on\n * `formSubmission` than any fixed first page, and every live one of them has\n * to run. This reads the event's documents in pages, in document-id order,\n * keeps the ones that may run, and hands back every one of them, in that\n * order, up to `max` — so which automations run never depends on how many\n * deleted or switched-off ones sort ahead of them.\n *\n * `deletedAt` and a missing `enabled` cannot be asked of a query — a field's\n * absence matches nothing — so `keep` judges them here, on every page.\n * Equality on `trigger.event` and an order by document id need only the\n * automatic single-field index. The reads are bounded twice: by `max`, and by\n * `maxReads` for a site whose deleted automations on one event have piled up.\n */\n\n/** One page of an event's automations. */\nexport const TRIGGERED_DOCS_PAGE = 100\n\nexport async function triggeredDocsForEvent(\n collection: FirebaseFirestore.CollectionReference,\n event: string,\n options: {\n keep: (doc: FirebaseFirestore.QueryDocumentSnapshot) => boolean\n max: number\n maxReads: number\n },\n): Promise<FirebaseFirestore.QueryDocumentSnapshot[]> {\n const kept: FirebaseFirestore.QueryDocumentSnapshot[] = []\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (let read = 0; read < options.maxReads; ) {\n let query = collection\n .where('trigger.event', '==', event)\n .orderBy('__name__')\n .limit(TRIGGERED_DOCS_PAGE)\n if (cursor) query = query.startAfter(cursor)\n const { docs } = await query.get()\n read += docs.length\n for (const doc of docs) if (options.keep(doc)) kept.push(doc)\n if (docs.length < TRIGGERED_DOCS_PAGE || kept.length >= options.max) break\n cursor = docs[docs.length - 1]\n }\n return kept.slice(0, options.max)\n}\n"],"names":["TRIGGERED_DOCS_PAGE","triggeredDocsForEvent","collection","event","options","kept","cursor","read","maxReads","query","where","orderBy","limit","startAfter","docs","get","length","doc","keep","push","max","slice"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;CAeC,GAED,wCAAwC,GACxC,OAAO,MAAMA,sBAAsB,IAAG;AAEtC,OAAO,eAAeC,sBACpBC,UAAiD,EACjDC,KAAa,EACbC,OAIC;IAED,MAAMC,OAAkD,EAAE;IAC1D,IAAIC,SAAyD;IAC7D,IAAK,IAAIC,OAAO,GAAGA,OAAOH,QAAQI,QAAQ,EAAI;QAC5C,IAAIC,QAAQP,WACTQ,KAAK,CAAC,iBAAiB,MAAMP,OAC7BQ,OAAO,CAAC,YACRC,KAAK,CAACZ;QACT,IAAIM,QAAQG,QAAQA,MAAMI,UAAU,CAACP;QACrC,MAAM,EAAEQ,IAAI,EAAE,GAAG,MAAML,MAAMM,GAAG;QAChCR,QAAQO,KAAKE,MAAM;QACnB,KAAK,MAAMC,OAAOH,KAAM,IAAIV,QAAQc,IAAI,CAACD,MAAMZ,KAAKc,IAAI,CAACF;QACzD,IAAIH,KAAKE,MAAM,GAAGhB,uBAAuBK,KAAKW,MAAM,IAAIZ,QAAQgB,GAAG,EAAE;QACrEd,SAASQ,IAAI,CAACA,KAAKE,MAAM,GAAG,EAAE;IAChC;IACA,OAAOX,KAAKgB,KAAK,CAAC,GAAGjB,QAAQgB,GAAG;AAClC"}
|
|
@@ -80,6 +80,15 @@ type ServerActionStep = ({
|
|
|
80
80
|
* actions editor, which the executor resolves to the default topic.
|
|
81
81
|
*/
|
|
82
82
|
topicId?: string;
|
|
83
|
+
/**
|
|
84
|
+
* A TRANSACTIONAL REPLY (AGL-3458): the message answers what the
|
|
85
|
+
* recipient just did — submitted a form, booked, signed up — so it goes
|
|
86
|
+
* out with no unsubscribe header and no unsubscribe link, the way the
|
|
87
|
+
* Inbox's reply to a submission does. Only a step that qualifies can be
|
|
88
|
+
* one ({@link sendEmailReplyIneligibility}); absent reads as on for a
|
|
89
|
+
* qualifying step, and `false` sends it as a mailing anyway.
|
|
90
|
+
*/
|
|
91
|
+
transactional?: boolean;
|
|
83
92
|
} | {
|
|
84
93
|
type: 'notifyAdmins';
|
|
85
94
|
title: string;
|
|
@@ -177,6 +186,50 @@ export type HostActionStep = ClientInteractionStep | ServerActionStep;
|
|
|
177
186
|
*/
|
|
178
187
|
export declare const FLOW_SUSPENDING_STEP_TYPES: ReadonlySet<HostActionStepType>;
|
|
179
188
|
export declare function isFlowSuspendingStep(step: HostActionStep): boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Whether the step at `index` runs after a wait — a `wait` or a
|
|
191
|
+
* `waitForEvent` earlier in the list — and so on the business's schedule
|
|
192
|
+
* rather than as the immediate response to the event.
|
|
193
|
+
*/
|
|
194
|
+
export declare function stepRunsAfterWait(steps: readonly HostActionStep[] | null | undefined, index: number): boolean;
|
|
195
|
+
/**
|
|
196
|
+
* Why a `sendEmail` step cannot be a TRANSACTIONAL REPLY (AGL-3458), or
|
|
197
|
+
* `null` when it can.
|
|
198
|
+
*
|
|
199
|
+
* A reply answers what the recipient just did, so all four have to hold:
|
|
200
|
+
*
|
|
201
|
+
* - `event` — the trigger is the recipient's own action: a form submitted,
|
|
202
|
+
* a booking, a sign-up, a new lead (`recipientActed` on the event's
|
|
203
|
+
* declaration). A stage change or a won deal is the business acting.
|
|
204
|
+
* - `wait` — the step runs at once. After a wait it goes out on the
|
|
205
|
+
* business's schedule, to somebody who did one thing once, which is a
|
|
206
|
+
* mailing (`marketing-send.ts`).
|
|
207
|
+
* - `topic` — the step names no email topic. A topic is a stream somebody
|
|
208
|
+
* can leave, which only a mailing belongs to.
|
|
209
|
+
* - `recipient` — it goes to the address the event carries, the person who
|
|
210
|
+
* acted, rather than through `toField` to somebody else.
|
|
211
|
+
*/
|
|
212
|
+
export type SendEmailReplyIneligibility = 'event' | 'wait' | 'topic' | 'recipient';
|
|
213
|
+
export declare function sendEmailReplyIneligibility(step: Pick<Extract<HostActionStep, {
|
|
214
|
+
type: 'sendEmail';
|
|
215
|
+
}>, 'type' | 'topicId' | 'toField'>, context: {
|
|
216
|
+
event: string | null | undefined;
|
|
217
|
+
afterWait: boolean;
|
|
218
|
+
}): SendEmailReplyIneligibility | null;
|
|
219
|
+
/** What the editor and the validator say about each — see {@link sendEmailReplyIneligibility}. */
|
|
220
|
+
export declare const SEND_EMAIL_REPLY_INELIGIBLE_REASONS: Record<SendEmailReplyIneligibility, string>;
|
|
221
|
+
/**
|
|
222
|
+
* Whether a `sendEmail` step goes out as a transactional reply: it qualifies,
|
|
223
|
+
* and its author did not switch the reply off. Absent is ON for a step that
|
|
224
|
+
* qualifies, so the auto-reply a site already sends to a form becomes one
|
|
225
|
+
* without anybody editing it.
|
|
226
|
+
*/
|
|
227
|
+
export declare function sendEmailIsTransactionalReply(step: Pick<Extract<HostActionStep, {
|
|
228
|
+
type: 'sendEmail';
|
|
229
|
+
}>, 'type' | 'topicId' | 'toField' | 'transactional'>, context: {
|
|
230
|
+
event: string | null | undefined;
|
|
231
|
+
afterWait: boolean;
|
|
232
|
+
}): boolean;
|
|
180
233
|
export type HostActionStepType = HostActionStep['type'];
|
|
181
234
|
/**
|
|
182
235
|
* `hosts/{hostId}/actions/{id}` doc: a site interaction whose steps may be
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { CONTACT_TAG_MAX_LENGTH, CRM_TASK_MAX_DUE_DAYS, isContactLifecycleStage, isCrmActivityKind, isCrmTaskKind } from "@aglyn/aglyn/app-utils/crm-kinds";
|
|
17
|
-
import { HOST_EVENT_TYPES } from "@aglyn/aglyn/app-utils/host-events";
|
|
17
|
+
import { HOST_EVENT_TYPES, hostEventRecipientActed } from "@aglyn/aglyn/app-utils/host-events";
|
|
18
18
|
import { CLIENT_ACTION_STEP_TYPES, CUSTOM_EVENT_PATTERN, interactionStepLabel, validateInteraction } from "@aglyn/aglyn/app-utils/site-interactions";
|
|
19
19
|
/** A task's priorities, by meaning (AGL-3517) — the `taskPriority` picklist's meanings. */ const TASK_PRIORITIES = [
|
|
20
20
|
'low',
|
|
@@ -59,6 +59,36 @@ import { CLIENT_ACTION_STEP_TYPES, CUSTOM_EVENT_PATTERN, interactionStepLabel, v
|
|
|
59
59
|
export function isFlowSuspendingStep(step) {
|
|
60
60
|
return FLOW_SUSPENDING_STEP_TYPES.has(step.type);
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether the step at `index` runs after a wait — a `wait` or a
|
|
64
|
+
* `waitForEvent` earlier in the list — and so on the business's schedule
|
|
65
|
+
* rather than as the immediate response to the event.
|
|
66
|
+
*/ export function stepRunsAfterWait(steps, index) {
|
|
67
|
+
return (steps != null ? steps : []).slice(0, Math.max(0, index)).some(isFlowSuspendingStep);
|
|
68
|
+
}
|
|
69
|
+
export function sendEmailReplyIneligibility(step, context) {
|
|
70
|
+
var _step_topicId, _step_toField;
|
|
71
|
+
if (!hostEventRecipientActed(context.event)) return 'event';
|
|
72
|
+
if (context.afterWait) return 'wait';
|
|
73
|
+
if (String((_step_topicId = step.topicId) != null ? _step_topicId : '').trim()) return 'topic';
|
|
74
|
+
const toField = String((_step_toField = step.toField) != null ? _step_toField : '').trim();
|
|
75
|
+
if (toField && toField !== 'email') return 'recipient';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** What the editor and the validator say about each — see {@link sendEmailReplyIneligibility}. */ export const SEND_EMAIL_REPLY_INELIGIBLE_REASONS = {
|
|
79
|
+
event: 'only a reply to the person’s own form submission, booking or sign-up can be ' + 'transactional',
|
|
80
|
+
wait: 'an email after a wait is a mailing, so it keeps its unsubscribe link',
|
|
81
|
+
topic: 'an email in a topic is a mailing, so it keeps its unsubscribe link',
|
|
82
|
+
recipient: 'only an email to the person who acted can be a transactional reply'
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Whether a `sendEmail` step goes out as a transactional reply: it qualifies,
|
|
86
|
+
* and its author did not switch the reply off. Absent is ON for a step that
|
|
87
|
+
* qualifies, so the auto-reply a site already sends to a form becomes one
|
|
88
|
+
* without anybody editing it.
|
|
89
|
+
*/ export function sendEmailIsTransactionalReply(step, context) {
|
|
90
|
+
return step.transactional !== false && sendEmailReplyIneligibility(step, context) === null;
|
|
91
|
+
}
|
|
62
92
|
/**
|
|
63
93
|
* The shortest and longest a flow may wait.
|
|
64
94
|
*
|
|
@@ -142,12 +172,30 @@ export const FLOW_WAIT_MAX_MINUTES = 90 * 24 * 60;
|
|
|
142
172
|
* Validates an action doc shape; returns a human-readable error or null.
|
|
143
173
|
* Server and console share this so bad steps never persist or run.
|
|
144
174
|
*/ export function validateHostAction(action) {
|
|
175
|
+
var _action_steps;
|
|
145
176
|
// The name, the recipe stamp, the trigger, the step guards, the client
|
|
146
177
|
// steps and every declared step's pick are the platform's to check; the
|
|
147
178
|
// server steps are this module's.
|
|
148
|
-
|
|
179
|
+
const problem = validateInteraction(action, {
|
|
149
180
|
validateStep: (step, label)=>hostActionStepProblem(step, label)
|
|
150
181
|
});
|
|
182
|
+
if (problem) return problem;
|
|
183
|
+
/*
|
|
184
|
+
* A step SWITCHED to a transactional reply has to be one (AGL-3458). The
|
|
185
|
+
* executor would send it as a mailing anyway — the unsubscribe is never
|
|
186
|
+
* dropped from mail that is not a reply — so saving the switch would show
|
|
187
|
+
* an author a promise the run does not keep.
|
|
188
|
+
*/ const steps = (_action_steps = action.steps) != null ? _action_steps : [];
|
|
189
|
+
for (const [index, step] of steps.entries()){
|
|
190
|
+
var _action_trigger;
|
|
191
|
+
if (step.type !== 'sendEmail' || step.transactional !== true) continue;
|
|
192
|
+
const why = sendEmailReplyIneligibility(step, {
|
|
193
|
+
event: (_action_trigger = action.trigger) == null ? void 0 : _action_trigger.event,
|
|
194
|
+
afterWait: stepRunsAfterWait(steps, index)
|
|
195
|
+
});
|
|
196
|
+
if (why) return `Step ${index + 1}: ${SEND_EMAIL_REPLY_INELIGIBLE_REASONS[why]}`;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
151
199
|
}
|
|
152
200
|
/**
|
|
153
201
|
* A server step's own complaint, or null. A `runWorkflow` step's pick is
|